diff --git a/.github/workflows/build_ws.yml b/.github/workflows/archived/build_ws.yml.archived similarity index 100% rename from .github/workflows/build_ws.yml rename to .github/workflows/archived/build_ws.yml.archived diff --git a/.github/workflows/publish_lsp.yml b/.github/workflows/archived/publish_lsp.yml.archived similarity index 100% rename from .github/workflows/publish_lsp.yml rename to .github/workflows/archived/publish_lsp.yml.archived diff --git a/.github/workflows/publish_extra.yml b/.github/workflows/publish_extra.yml new file mode 100644 index 0000000000..27c62d2448 --- /dev/null +++ b/.github/workflows/publish_extra.yml @@ -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 diff --git a/Caddyfile b/Caddyfile index 933407b98d..947e0522cb 100644 --- a/Caddyfile +++ b/Caddyfile @@ -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 } diff --git a/backend/.sqlx/query-50f81c128d644c60837e603099e169fbc7a500c11e79d2c78d6eccccf6491aec.json b/backend/.sqlx/query-50f81c128d644c60837e603099e169fbc7a500c11e79d2c78d6eccccf6491aec.json new file mode 100644 index 0000000000..63243f0b4f --- /dev/null +++ b/backend/.sqlx/query-50f81c128d644c60837e603099e169fbc7a500c11e79d2c78d6eccccf6491aec.json @@ -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" +} diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 713ccb9dd3..36ddb8ab9f 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - null + true ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-928aa6e4fff9f60a14a51cc7a3ef507414d20c81833bc940c6323fcdbee5d9b3.json b/backend/.sqlx/query-928aa6e4fff9f60a14a51cc7a3ef507414d20c81833bc940c6323fcdbee5d9b3.json new file mode 100644 index 0000000000..fa83f0e2b1 --- /dev/null +++ b/backend/.sqlx/query-928aa6e4fff9f60a14a51cc7a3ef507414d20c81833bc940c6323fcdbee5d9b3.json @@ -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" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d67baee4ac..62aaaa97e4 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -15288,6 +15288,7 @@ dependencies = [ "datafusion", "deno_core", "deno_error", + "ed25519-dalek", "futures", "git-version", "google-cloud-googleapis", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e7f5ba6e2a..fd5d60ef6c 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -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" diff --git a/backend/src/main.rs b/backend/src/main.rs index 54fb29f272..af4781f6f0 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -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(()); + } _ => {} } diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index f2647f9481..1a7ceda958 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -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 diff --git a/backend/windmill-api/src/debug.rs b/backend/windmill-api/src/debug.rs new file mode 100644 index 0000000000..fc5e8084c5 --- /dev/null +++ b/backend/windmill-api/src/debug.rs @@ -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>> = 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, +} + +/// 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 { + 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, + Path(w_id): Path, + Json(request): Json, +) -> JsonResult { + 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, + ) + .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, + ) + .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, + Path(w_id): Path, + Json(request): Json, +) -> JsonResult { + 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 })) +} diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index eeee4af1cc..cb344cb6fc 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -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))), diff --git a/backend/windmill-worker/src/lib.rs b/backend/windmill-worker/src/lib.rs index d211695091..dc82ddf2b5 100644 --- a/backend/windmill-worker/src/lib.rs +++ b/backend/windmill-worker/src/lib.rs @@ -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; diff --git a/backend/windmill-worker/src/prepare_deps.rs b/backend/windmill-worker/src/prepare_deps.rs new file mode 100644 index 0000000000..70b5718cf7 --- /dev/null +++ b/backend/windmill-worker/src/prepare_deps.rs @@ -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, + /// Path to Python virtual environment's site-packages + pub venv_path: Option, + pub job_dir: String, + pub success: bool, + pub error: Option, +} + +/// 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 { + 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 { + 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 { + 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(()) +} diff --git a/debugger/.dockerignore b/debugger/.dockerignore new file mode 100644 index 0000000000..1c3a6fd3e4 --- /dev/null +++ b/debugger/.dockerignore @@ -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 diff --git a/debugger/Dockerfile b/debugger/Dockerfile new file mode 100644 index 0000000000..04b118a0de --- /dev/null +++ b/debugger/Dockerfile @@ -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"] diff --git a/debugger/README.md b/debugger/README.md new file mode 100644 index 0000000000..3caed251aa --- /dev/null +++ b/debugger/README.md @@ -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 + + + +``` + +## 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 +``` diff --git a/debugger/dap_debug_service.ts b/debugger/dap_debug_service.ts new file mode 100644 index 0000000000..024c7beeee --- /dev/null +++ b/debugger/dap_debug_service.ts @@ -0,0 +1,1173 @@ +#!/usr/bin/env bun +/** + * Unified DAP Debug Service + * + * A containerizable WebSocket service that routes debug requests to either + * Python (debugpy) or TypeScript/Bun debuggers based on the endpoint hit. + * + * Endpoints: + * /python - Python debugging via dap_websocket_server.py (bdb-based) + * /typescript - TypeScript/Bun debugging via WebKit Inspector + * /bun - Alias for /typescript + * + * Designed to be compatible with nsjail wrapping for sandboxed execution. + * + * Usage: + * bun run dap_debug_service.ts [options] + * + * Options: + * --port PORT Server port (default: 5679) + * --host HOST Server host (default: 0.0.0.0) + * --nsjail Enable nsjail wrapping + * --nsjail-config PATH Path to nsjail config file + * --windmill PATH Path to windmill binary for automatic dependency installation + * --debug Enable debug logging + * + * Environment Variables: + * DAP_PORT Server port + * DAP_HOST Server host + * DAP_NSJAIL_ENABLED Enable nsjail (true/false) + * DAP_NSJAIL_CONFIG Path to nsjail config file + * DAP_NSJAIL_PATH Path to nsjail binary (default: nsjail) + * DAP_WINDMILL_PATH Path to windmill binary for dependency auto-installation + * DAP_DEBUG Enable debug logging (true/false) + */ + +import { spawn, type Subprocess } from 'bun' +import { mkdtemp, writeFile, unlink, rmdir } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// Import the working Bun debug session from the standalone server +import { DebugSession as BunDebugSessionWorking, type NsjailConfig } from './dap_websocket_server_bun' + +// ============================================================================ +// Configuration +// ============================================================================ + +interface ServiceConfig { + port: number + host: string + nsjail: { + enabled: boolean + configPath?: string + binaryPath: string + // Additional nsjail options that can be passed + extraArgs: string[] + } + // Paths to debugger binaries (can be overridden for containerized deployments) + pythonPath: string + bunPath: string + // Windmill binary path for prepare-deps CLI (optional, for dependency auto-installation) + windmillPath?: string + // Enable debug logging + debug: boolean +} + +function parseConfig(): ServiceConfig { + const args = process.argv.slice(2) + const config: ServiceConfig = { + port: parseInt(process.env.DAP_PORT || '5679', 10), + host: process.env.DAP_HOST || '0.0.0.0', + nsjail: { + enabled: process.env.DAP_NSJAIL_ENABLED === 'true', + configPath: process.env.DAP_NSJAIL_CONFIG, + binaryPath: process.env.DAP_NSJAIL_PATH || 'nsjail', + extraArgs: [] + }, + pythonPath: process.env.DAP_PYTHON_PATH || 'python3', + bunPath: process.env.DAP_BUN_PATH || 'bun', + windmillPath: process.env.DAP_WINDMILL_PATH, + debug: process.env.DAP_DEBUG === 'true' + } + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--port': + config.port = parseInt(args[++i], 10) + break + case '--host': + config.host = args[++i] + break + case '--nsjail': + config.nsjail.enabled = true + break + case '--nsjail-config': + config.nsjail.configPath = args[++i] + break + case '--nsjail-path': + config.nsjail.binaryPath = args[++i] + break + case '--python-path': + config.pythonPath = args[++i] + break + case '--bun-path': + config.bunPath = args[++i] + break + case '--windmill': + config.windmillPath = args[++i] + break + case '--debug': + config.debug = true + break + } + } + + return config +} + +const config = parseConfig() + +// Validate windmill path exists if specified +if (config.windmillPath && !existsSync(config.windmillPath)) { + console.error(`ERROR: Windmill binary not found at: ${config.windmillPath}`) + console.error('Please provide a valid path with --windmill /path/to/windmill') + process.exit(1) +} + +// ============================================================================ +// Logging +// ============================================================================ + +const LOG_LEVEL = process.env.LOG_LEVEL || 'INFO' +const logger = { + debug: (...args: unknown[]) => { + if (LOG_LEVEL === 'DEBUG') console.log('[DEBUG]', new Date().toISOString(), ...args) + }, + info: (...args: unknown[]) => console.log('[INFO]', new Date().toISOString(), ...args), + warn: (...args: unknown[]) => console.warn('[WARN]', new Date().toISOString(), ...args), + error: (...args: unknown[]) => console.error('[ERROR]', new Date().toISOString(), ...args) +} + +// ============================================================================ +// JWT Token Verification +// ============================================================================ + +// JWT verification for debug requests +// The debugger fetches the public key from the Windmill backend's JWKS endpoint +const WINDMILL_BASE_URL = process.env.WINDMILL_BASE_URL || process.env.BASE_INTERNAL_URL // e.g., http://localhost:8000 +const REQUIRE_SIGNED_REQUESTS = process.env.REQUIRE_SIGNED_DEBUG_REQUESTS !== 'false' + +interface JWK { + kty: string + crv: string + x: string + kid: string + use: string + alg: string +} + +interface JWKS { + keys: JWK[] +} + +interface DebugTokenClaims { + code_hash: string + language: string + workspace_id: string + email: string + iat: number + exp: number + job_id: string +} + +// Cached public key +let cachedPublicKey: CryptoKey | null = null +let publicKeyFetchPromise: Promise | null = null + +/** + * Fetch and cache the Ed25519 public key from the JWKS endpoint. + */ +async function getPublicKey(): Promise { + if (cachedPublicKey) { + return cachedPublicKey + } + + if (publicKeyFetchPromise) { + return publicKeyFetchPromise + } + + if (!WINDMILL_BASE_URL) { + logger.warn('WINDMILL_BASE_URL not set - cannot fetch public key') + return null + } + + publicKeyFetchPromise = (async () => { + try { + const jwksUrl = `${WINDMILL_BASE_URL.replace(/\/$/, '')}/api/debug/jwks` + logger.info(`Fetching JWKS from ${jwksUrl}`) + const response = await fetch(jwksUrl) + if (!response.ok) { + throw new Error(`Failed to fetch JWKS: ${response.status} ${response.statusText}`) + } + + const jwks: JWKS = await response.json() + if (!jwks.keys || jwks.keys.length === 0) { + throw new Error('No keys in JWKS') + } + + const jwk = jwks.keys[0] + if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') { + throw new Error(`Unsupported key type: ${jwk.kty}/${jwk.crv}`) + } + + // Decode the public key from base64url + const publicKeyBytes = Uint8Array.from(atob(jwk.x.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)) + + // Import as Ed25519 public key + const key = await crypto.subtle.importKey( + 'raw', + publicKeyBytes, + { name: 'Ed25519' }, + true, + ['verify'] + ) + + cachedPublicKey = key + logger.info('Successfully loaded Ed25519 public key from JWKS') + return key + } catch (error) { + logger.error(`Failed to fetch/parse JWKS: ${error}`) + return null + } finally { + publicKeyFetchPromise = null + } + })() + + return publicKeyFetchPromise +} + +/** + * Compute SHA-256 hash of code and return first 16 bytes as hex. + */ +async function computeCodeHash(code: string): Promise { + const encoder = new TextEncoder() + const data = encoder.encode(code) + const hashBuffer = await crypto.subtle.digest('SHA-256', data) + const hashArray = new Uint8Array(hashBuffer) + return Array.from(hashArray.slice(0, 16)) + .map(b => b.toString(16).padStart(2, '0')) + .join('') +} + +/** + * Base64url decode + */ +function base64urlDecode(str: string): Uint8Array { + // Add padding if needed + const padding = '='.repeat((4 - str.length % 4) % 4) + const base64 = str.replace(/-/g, '+').replace(/_/g, '/') + padding + const binary = atob(base64) + return Uint8Array.from(binary, c => c.charCodeAt(0)) +} + +/** + * Verify a JWT debug token. + * Returns null if valid, or an error message if invalid. + */ +async function verifyDebugToken(token: string, code: string): Promise { + const publicKey = await getPublicKey() + + if (!publicKey) { + if (REQUIRE_SIGNED_REQUESTS) { + return 'Public key not available but signed requests are required. Set WINDMILL_BASE_URL.' + } + logger.warn('Public key not available - signature verification disabled') + return null + } + + // Parse JWT + const parts = token.split('.') + if (parts.length !== 3) { + return 'Invalid JWT format' + } + + const [headerB64, claimsB64, signatureB64] = parts + + try { + // Verify signature + const message = new TextEncoder().encode(`${headerB64}.${claimsB64}`) + const signature = base64urlDecode(signatureB64) + + const isValid = await crypto.subtle.verify( + { name: 'Ed25519' }, + publicKey, + signature, + message + ) + + if (!isValid) { + return 'Invalid JWT signature' + } + + // Parse and validate claims + const claimsJson = new TextDecoder().decode(base64urlDecode(claimsB64)) + const claims: DebugTokenClaims = JSON.parse(claimsJson) + + // Check expiration + const now = Math.floor(Date.now() / 1000) + if (now > claims.exp) { + return `Token expired: ${now - claims.exp} seconds ago` + } + + // Verify code hash + const expectedHash = await computeCodeHash(code) + if (claims.code_hash !== expectedHash) { + return 'Code hash mismatch - code was modified after signing' + } + + logger.info(`Verified debug token from ${claims.email} in workspace ${claims.workspace_id} (job: ${claims.job_id})`) + return null + } catch (error) { + return `JWT verification error: ${error}` + } +} + +// ============================================================================ +// Process Spawning with nsjail Support +// ============================================================================ + +interface SpawnOptions { + cmd: string[] + cwd?: string + env?: Record + stdout?: 'pipe' | 'inherit' + stderr?: 'pipe' | 'inherit' +} + +/** + * Spawn a process, optionally wrapped with nsjail. + * This is the key function for sandboxed execution. + */ +function spawnProcess(options: SpawnOptions): Subprocess { + let cmd = options.cmd + + if (config.nsjail.enabled) { + // Build nsjail command + const nsjailCmd = [config.nsjail.binaryPath] + + // Add config file if specified + if (config.nsjail.configPath) { + nsjailCmd.push('--config', config.nsjail.configPath) + } + + // Add any extra nsjail arguments + nsjailCmd.push(...config.nsjail.extraArgs) + + // Add working directory binding if specified + if (options.cwd) { + nsjailCmd.push('--cwd', options.cwd) + } + + // Separator and actual command + nsjailCmd.push('--') + nsjailCmd.push(...cmd) + + cmd = nsjailCmd + logger.info(`Spawning with nsjail: ${cmd.join(' ')}`) + } else { + logger.info(`Spawning: ${cmd.join(' ')}`) + } + + // Only include essential env vars + caller-provided ones + // Don't inherit all of process.env to keep debugger environment clean + return spawn({ + cmd, + cwd: options.cwd || process.cwd(), + stdout: options.stdout || 'pipe', + stderr: options.stderr || 'pipe', + env: { + // Essential system vars + PATH: process.env.PATH || '/usr/bin:/bin', + HOME: process.env.HOME, + // Caller-provided env vars + // Note: WM_BASE_URL is already overridden by BASE_INTERNAL_URL if set + ...options.env + } + }) +} + +// ============================================================================ +// DAP Types +// ============================================================================ + +interface DAPMessage { + seq: number + type: 'request' | 'response' | 'event' + command?: string + event?: string + request_seq?: number + success?: boolean + message?: string + body?: Record + arguments?: Record +} + +// ============================================================================ +// Base Debug Session +// ============================================================================ + +abstract class BaseDebugSession { + protected ws: { send: (data: string) => void; close: () => void } + protected seq = 1 + protected initialized = false + protected configured = false + protected running = false + protected terminatedSent = false + + protected process: Subprocess | null = null + protected scriptPath: string | null = null + protected tempDir: string | null = null + protected tempFile: string | null = null + protected breakpoints = new Map() + protected callMain = false + protected mainArgs: Record = {} + protected scriptResult: unknown = undefined + + constructor(ws: { send: (data: string) => void; close: () => void }) { + this.ws = ws + } + + protected nextSeq(): number { + return this.seq++ + } + + protected sendMessage(msg: DAPMessage): void { + const data = JSON.stringify(msg) + logger.debug('Sending DAP:', data) + this.ws.send(data) + } + + protected sendResponse( + request: DAPMessage, + success = true, + body: Record = {}, + message = '' + ): void { + this.sendMessage({ + seq: this.nextSeq(), + type: 'response', + command: request.command || '', + request_seq: request.seq, + success, + message, + body + }) + } + + protected sendEvent(event: string, body: Record = {}): void { + this.sendMessage({ + seq: this.nextSeq(), + type: 'event', + event, + body + }) + } + + abstract handleRequest(request: DAPMessage): Promise + abstract cleanup(): Promise +} + +// ============================================================================ +// Python Debug Session +// ============================================================================ + +class PythonDebugSession extends BaseDebugSession { + private debugpyWs: WebSocket | null = null + private debugpySeq = 1 + private pendingDebugpyRequests = new Map< + number, + { resolve: (value: DAPMessage) => void; reject: (error: Error) => void } + >() + private callFrames: Array<{ id: number; name: string; line: number; column: number; source?: { path: string; name?: string } }> = [] + private variablesRefCounter = 1 + private scopesMap = new Map() + private scriptResult: unknown = undefined + private envVars: Record = {} + private windmillPath?: string + private debugMode: boolean + + constructor(ws: { send: (data: string) => void; close: () => void }, windmillPath?: string, debugMode = false) { + super(ws) + this.windmillPath = windmillPath + this.debugMode = debugMode + } + + private nextDebugpySeq(): number { + return this.debugpySeq++ + } + + private nextVarRef(): number { + return this.variablesRefCounter++ + } + + private async sendDebugpyRequest(command: string, args?: Record): Promise { + if (!this.debugpyWs || this.debugpyWs.readyState !== WebSocket.OPEN) { + throw new Error('Debugpy not connected') + } + + const seq = this.nextDebugpySeq() + const message: DAPMessage = { + seq, + type: 'request', + command, + arguments: args + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pendingDebugpyRequests.delete(seq) + reject(new Error(`Debugpy command timeout: ${command}`)) + }, 10000) + + this.pendingDebugpyRequests.set(seq, { + resolve: (value) => { + clearTimeout(timeout) + resolve(value) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + } + }) + + logger.debug('Sending to debugpy:', JSON.stringify(message)) + this.debugpyWs!.send(JSON.stringify(message)) + }) + } + + private handleDebugpyMessage(data: string): void { + try { + const message: DAPMessage = JSON.parse(data) + logger.debug('Debugpy message:', data.substring(0, 200)) + + if (message.type === 'response') { + const pending = this.pendingDebugpyRequests.get(message.request_seq!) + if (pending) { + this.pendingDebugpyRequests.delete(message.request_seq!) + if (message.success) { + pending.resolve(message) + } else { + pending.reject(new Error(message.message || 'Request failed')) + } + } + } else if (message.type === 'event') { + this.handleDebugpyEvent(message) + } + } catch (error) { + logger.error('Failed to parse debugpy message:', error) + } + } + + private handleDebugpyEvent(event: DAPMessage): void { + const body = event.body || {} + + switch (event.event) { + case 'initialized': + this.sendEvent('initialized') + break + case 'stopped': + this.sendEvent('stopped', { + reason: body.reason || 'breakpoint', + threadId: body.threadId || 1, + allThreadsStopped: body.allThreadsStopped ?? true, + line: body.line + }) + break + case 'continued': + this.sendEvent('continued', { threadId: body.threadId || 1 }) + break + case 'terminated': + if (!this.terminatedSent) { + this.terminatedSent = true + // Include captured result in terminated event + this.sendEvent('terminated', { ...body, result: this.scriptResult }) + } + break + case 'output': { + // Capture the result from __WINDMILL_RESULT__ output + const output = body.output as string | undefined + if (output && output.startsWith('__WINDMILL_RESULT__:')) { + try { + const resultJson = output.substring('__WINDMILL_RESULT__:'.length).trim() + this.scriptResult = JSON.parse(resultJson) + logger.info(`Python: Captured script result: ${resultJson}`) + } catch (error) { + logger.error('Failed to parse Python result:', error) + } + // Don't forward __WINDMILL_RESULT__ output to client + break + } + this.sendEvent('output', body) + break + } + case 'exited': + if (!this.terminatedSent) { + this.terminatedSent = true + this.sendEvent('terminated', { result: this.scriptResult }) + } + break + } + } + + private async startPythonProcess(cwd: string): Promise { + if (!this.scriptPath) { + throw new Error('No script path') + } + + // Use a wider port range (10000-60000) to avoid collisions with recently used ports + const debugpyPort = 10000 + Math.floor(Math.random() * 50000) + + // Get the directory where this script is located to find dap_websocket_server.py + const scriptDir = import.meta.dir + + // Spawn the Python WebSocket DAP server + // Pass env vars to the server - it will forward them to the debugged script + const cmd = [ + config.pythonPath, + '-u', + join(scriptDir, 'dap_websocket_server.py'), + '--port', String(debugpyPort), + '--host', '127.0.0.1' + ] + + // Pass windmill path for dependency auto-installation if configured + if (this.windmillPath) { + cmd.push('--windmill', this.windmillPath) + logger.info(`Python session: autoinstall enabled with windmill at ${this.windmillPath}`) + } + + // Pass debug flag to Python subprocess + if (this.debugMode) { + cmd.push('--debug') + } + + this.process = spawnProcess({ + cmd, + cwd, + env: { PYTHONUNBUFFERED: '1', ...this.envVars } + }) + + // Read stderr to capture startup messages + this.readPythonStderr(this.process.stderr) + + // Wait for the Python server to be ready (check via health endpoint or just wait) + await this.waitForPythonServer(debugpyPort) + + // Connect to the Python WebSocket server + await this.connectToDebugpy(`ws://127.0.0.1:${debugpyPort}`) + + this.process.exited.then(async (exitCode) => { + logger.info(`Python process exited with code: ${exitCode}`) + this.running = false + + if (!this.terminatedSent) { + this.terminatedSent = true + this.sendEvent('terminated', this.scriptResult !== undefined ? { result: this.scriptResult } : {}) + } + + await this.cleanup() + }) + } + + private async readPythonStderr(stream: ReadableStream | null): Promise { + if (!stream) return + + const reader = stream.getReader() + const decoder = new TextDecoder() + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + const text = decoder.decode(value) + // Forward stderr output to the client (Python logs go to stderr) + if (text.trim()) { + // Log Python output at info level so we can see it + for (const line of text.split('\n')) { + if (line.trim()) { + logger.info(`[Python] ${line}`) + } + } + } + } + } catch (error) { + logger.debug('Python stderr stream ended:', error) + } + } + + private async waitForPythonServer(port: number, maxAttempts = 30): Promise { + // Wait a bit for Python to start - it takes at least 50-100ms to initialize + await new Promise(resolve => setTimeout(resolve, 100)) + + for (let i = 0; i < maxAttempts; i++) { + try { + // Try to connect to see if server is up + const testWs = new WebSocket(`ws://127.0.0.1:${port}`) + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + try { testWs.close() } catch {} + reject(new Error('Connection timeout')) + }, 500) + + testWs.onopen = () => { + clearTimeout(timeout) + // Wait a moment before closing to ensure the connection is stable + setTimeout(() => { + try { testWs.close() } catch {} + resolve() + }, 50) + } + testWs.onerror = () => { + clearTimeout(timeout) + try { testWs.close() } catch {} + reject(new Error('Connection failed')) + } + }) + // Small delay to let the test connection fully close + await new Promise(resolve => setTimeout(resolve, 50)) + logger.info(`Python server ready on port ${port}`) + return + } catch { + await new Promise(resolve => setTimeout(resolve, 100)) + } + } + throw new Error('Python server did not start in time') + } + + private async connectToDebugpy(wsUrl: string): Promise { + logger.info(`Connecting to debugpy at ${wsUrl}`) + + return new Promise((resolve, reject) => { + this.debugpyWs = new WebSocket(wsUrl) + + const timeout = setTimeout(() => { + reject(new Error('Debugpy connection timeout')) + }, 5000) + + this.debugpyWs.onopen = async () => { + clearTimeout(timeout) + logger.info('Connected to debugpy') + + try { + await this.sendDebugpyRequest('initialize', { + clientID: 'windmill', + clientName: 'Windmill Debug Service', + adapterID: 'python', + pathFormat: 'path', + linesStartAt1: true, + columnsStartAt1: true + }) + this.running = true + resolve() + } catch (error) { + reject(error) + } + } + + this.debugpyWs.onmessage = (event) => { + this.handleDebugpyMessage(event.data as string) + } + + this.debugpyWs.onerror = (error) => { + logger.error('Debugpy WebSocket error:', error) + } + + this.debugpyWs.onclose = () => { + logger.info('Debugpy WebSocket closed') + this.debugpyWs = null + } + }) + } + + async handleRequest(request: DAPMessage): Promise { + const command = request.command || '' + + switch (command) { + case 'initialize': + await this.handleInitialize(request) + break + case 'setBreakpoints': + await this.handleSetBreakpoints(request) + break + case 'configurationDone': + this.configured = true + if (this.debugpyWs) { + await this.sendDebugpyRequest('configurationDone') + } + this.sendResponse(request) + break + case 'launch': + await this.handleLaunch(request) + break + case 'threads': + await this.forwardToDebugpy(request) + break + case 'stackTrace': + await this.forwardToDebugpy(request) + break + case 'scopes': + await this.forwardToDebugpy(request) + break + case 'variables': + await this.forwardToDebugpy(request) + break + case 'evaluate': + await this.forwardToDebugpy(request) + break + case 'continue': + await this.forwardToDebugpy(request) + break + case 'next': + await this.forwardToDebugpy(request) + break + case 'stepIn': + await this.forwardToDebugpy(request) + break + case 'stepOut': + await this.forwardToDebugpy(request) + break + case 'pause': + await this.forwardToDebugpy(request) + break + case 'disconnect': + case 'terminate': + await this.handleTerminate(request) + break + default: + this.sendResponse(request, false, {}, `Unsupported command: ${command}`) + } + } + + private async forwardToDebugpy(request: DAPMessage): Promise { + try { + const response = await this.sendDebugpyRequest(request.command!, request.arguments) + this.sendResponse(request, response.success, response.body, response.message) + } catch (error) { + this.sendResponse(request, false, {}, String(error)) + } + } + + private async handleInitialize(request: DAPMessage): Promise { + this.sendResponse(request, true, { + supportsConfigurationDoneRequest: true, + supportsEvaluateForHovers: true, + supportTerminateDebuggee: true, + supportsTerminateRequest: true + }) + this.initialized = true + } + + private async handleSetBreakpoints(request: DAPMessage): Promise { + const args = request.arguments || {} + const source = args.source as { path?: string } | undefined + const sourcePath = source?.path || '' + const breakpointsData = (args.breakpoints as Array<{ line: number }>) || [] + + const lineNumbers = breakpointsData.map(bp => bp.line) + this.breakpoints.set(sourcePath, lineNumbers) + + if (this.debugpyWs) { + try { + const response = await this.sendDebugpyRequest('setBreakpoints', args) + this.sendResponse(request, response.success, response.body) + } catch (error) { + this.sendResponse(request, false, {}, String(error)) + } + } else { + // Debugpy not connected yet, respond with unverified breakpoints + const breakpoints = lineNumbers.map((line, i) => ({ + id: i + 1, + verified: false, + line, + source: { path: sourcePath } + })) + this.sendResponse(request, true, { breakpoints }) + } + } + + private async handleLaunch(request: DAPMessage): Promise { + const args = request.arguments || {} + let code = args.code as string | undefined + this.scriptPath = args.program as string | undefined + const cwd = (args.cwd as string) || process.cwd() + this.callMain = (args.callMain as boolean) || false + this.mainArgs = (args.args as Record) || {} + this.envVars = (args.env as Record) || {} + + // Verify JWT token if code is provided (signed debug request) + // The token is passed in the launch arguments + if (code && REQUIRE_SIGNED_REQUESTS) { + const token = args.token as string | undefined + if (!token) { + logger.error('No debug token provided but signed requests are required') + this.sendResponse(request, false, {}, 'Debug token required. Ensure the debug session was signed by the backend.') + return + } + + const verificationError = await verifyDebugToken(token, code) + if (verificationError) { + logger.error(`Token verification failed: ${verificationError}`) + this.sendResponse(request, false, {}, `Token verification failed: ${verificationError}`) + return + } + } + + // If BASE_INTERNAL_URL is set on the server, use it to override WM_BASE_URL + if (process.env.BASE_INTERNAL_URL) { + this.envVars.WM_BASE_URL = process.env.BASE_INTERNAL_URL + } + + if (Object.keys(this.envVars).length > 0) { + logger.info(`Python launch with env vars: ${Object.keys(this.envVars).join(', ')}`) + } + + if (!this.scriptPath && !code) { + this.sendResponse(request, false, {}, 'No program or code specified') + return + } + + // If callMain is true, wrap the code to call main() with args + if (this.callMain && code) { + const argsJson = JSON.stringify(this.mainArgs) + code += ` + +# Auto-generated call to main entrypoint +import json +import sys +_args = json.loads('${argsJson.replace(/'/g, "\\'")}') +_result = main(**_args) +print("__WINDMILL_RESULT__:" + json.dumps(_result)) +sys.stdout.flush() +` + } + + if (code && !this.scriptPath) { + try { + this.tempDir = await mkdtemp(join(tmpdir(), 'windmill_debug_')) + this.tempFile = join(this.tempDir, 'script.py') + await writeFile(this.tempFile, code) + this.scriptPath = this.tempFile + } catch (error) { + this.sendResponse(request, false, {}, `Failed to create temp file: ${error}`) + return + } + } + + this.sendResponse(request) + + try { + await this.startPythonProcess(cwd) + + // Re-apply breakpoints to the Python server using the actual script path + for (const [, lines] of this.breakpoints) { + await this.sendDebugpyRequest('setBreakpoints', { + source: { path: this.scriptPath }, + breakpoints: lines.map(line => ({ line })) + }) + } + + // Signal configuration done + await this.sendDebugpyRequest('configurationDone') + + // Now forward the launch command to the Python server + // The Python server needs to know what code/program to debug + await this.sendDebugpyRequest('launch', { + program: this.scriptPath, + code: code, + args: this.mainArgs, + cwd: cwd, + callMain: this.callMain, + env: this.envVars + }) + } catch (error) { + this.sendEvent('output', { category: 'stderr', output: `Failed to start Python: ${error}\n` }) + this.sendEvent('terminated', { error: String(error) }) + } + } + + private async handleTerminate(request: DAPMessage): Promise { + this.running = false + const shouldSendTerminated = !this.terminatedSent + this.terminatedSent = true + + if (this.debugpyWs) { + try { + await this.sendDebugpyRequest('terminate') + } catch {} + } + + await this.cleanup() + this.sendResponse(request) + + if (shouldSendTerminated) { + this.sendEvent('terminated', this.scriptResult !== undefined ? { result: this.scriptResult } : {}) + } + } + + async cleanup(): Promise { + if (this.debugpyWs) { + this.debugpyWs.close() + this.debugpyWs = null + } + + if (this.process) { + this.process.kill() + this.process = null + } + + if (this.tempFile) { + try { + await unlink(this.tempFile) + } catch {} + this.tempFile = null + } + + if (this.tempDir) { + try { + await rmdir(this.tempDir) + } catch {} + this.tempDir = null + } + } +} + +// ============================================================================ +// Main Server +// ============================================================================ + +const sessions = new Map() + +logger.info(`Starting DAP Debug Service on ${config.host}:${config.port}`) +logger.info(`Endpoints: /python, /typescript, /bun`) +if (config.nsjail.enabled) { + logger.info(`nsjail enabled: ${config.nsjail.binaryPath}`) + if (config.nsjail.configPath) { + logger.info(`nsjail config: ${config.nsjail.configPath}`) + } +} +if (config.windmillPath) { + logger.info(`Windmill binary: ${config.windmillPath} (autoinstall enabled)`) +} else { + logger.info('Windmill binary: not configured (autoinstall disabled)') +} +if (config.debug) { + logger.info('Debug logging: enabled') +} + +const server = Bun.serve({ + hostname: config.host, + port: config.port, + fetch(req, server) { + const url = new URL(req.url) + const path = url.pathname + + // Handle WebSocket upgrade with path-based routing + if (server.upgrade(req, { data: { path } })) { + return undefined as unknown as Response + } + + // Health check endpoint + if (path === '/health') { + return new Response(JSON.stringify({ + status: 'ok', + endpoints: ['/python', '/typescript', '/bun'], + nsjail: config.nsjail.enabled + }), { + headers: { 'Content-Type': 'application/json' } + }) + } + + return new Response('DAP Debug Service\n\nEndpoints:\n /python - Python debugging\n /typescript - TypeScript/Bun debugging\n /bun - TypeScript/Bun debugging\n /health - Health check', { + status: 200 + }) + }, + websocket: { + open(ws) { + const path = (ws.data as { path: string }).path + logger.info(`New client connected: ${path}`) + + // Create appropriate session based on path + const wsWrapper = { + send: (data: string) => ws.send(data), + close: () => ws.close() + } + + let session: BaseDebugSession + + // Build nsjail config to pass to debuggers + const nsjailConfig: NsjailConfig | undefined = config.nsjail.enabled + ? { + enabled: true, + binaryPath: config.nsjail.binaryPath, + configPath: config.nsjail.configPath, + extraArgs: config.nsjail.extraArgs + } + : undefined + + if (path === '/python') { + session = new PythonDebugSession(wsWrapper, config.windmillPath, config.debug) + } else if (path === '/typescript' || path === '/bun' || path === '/') { + // Use the working Bun debug session from dap_websocket_server_bun.ts + session = new BunDebugSessionWorking(wsWrapper as unknown as WebSocket, { + nsjailConfig, + bunPath: config.bunPath, + windmillPath: config.windmillPath + }) as unknown as BaseDebugSession + } else { + logger.warn(`Unknown path: ${path}, defaulting to TypeScript`) + session = new BunDebugSessionWorking(wsWrapper as unknown as WebSocket, { + nsjailConfig, + bunPath: config.bunPath, + windmillPath: config.windmillPath + }) as unknown as BaseDebugSession + } + + sessions.set(ws, session) + }, + async message(ws, message) { + const session = sessions.get(ws) + if (!session) return + + try { + const data = JSON.parse(message as string) as DAPMessage + logger.debug('Received:', JSON.stringify(data).substring(0, 200)) + + if (data.type === 'request') { + await session.handleRequest(data) + } + } catch (error) { + logger.error('Error handling message:', error) + } + }, + async close(ws) { + logger.info('Client disconnected') + const session = sessions.get(ws) + if (session) { + await session.cleanup() + sessions.delete(ws) + } + } + } +}) + +logger.info(`Server started on ${server.url}`) + +// Handle graceful shutdown +process.on('SIGTERM', async () => { + logger.info('Received SIGTERM, shutting down...') + for (const session of sessions.values()) { + await session.cleanup() + } + process.exit(0) +}) + +process.on('SIGINT', async () => { + logger.info('Received SIGINT, shutting down...') + for (const session of sessions.values()) { + await session.cleanup() + } + process.exit(0) +}) diff --git a/debugger/dap_websocket_server.py b/debugger/dap_websocket_server.py new file mode 100644 index 0000000000..624b339894 --- /dev/null +++ b/debugger/dap_websocket_server.py @@ -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 (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 - don't include debugger/threading internals + if name == "": + 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": "", + "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 = "" + 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") diff --git a/debugger/dap_websocket_server_bun.ts b/debugger/dap_websocket_server_bun.ts new file mode 100644 index 0000000000..8db0d853ce --- /dev/null +++ b/debugger/dap_websocket_server_bun.ts @@ -0,0 +1,2475 @@ +#!/usr/bin/env bun +/** + * Lightweight DAP (Debug Adapter Protocol) WebSocket Server for Bun/TypeScript debugging. + * + * This server acts as a bridge between a WebSocket client (Monaco editor) and Bun's + * built-in debugging capabilities using the WebKit Inspector Protocol (not V8/Chrome DevTools). + * + * Key differences from V8/Node.js debugging: + * - Uses WebKit Inspector Protocol (similar to Safari DevTools) + * - Requires Inspector.enable, Debugger.setPauseOnDebuggerStatements, and Inspector.initialized + * - Breakpoints must be activated with Debugger.setBreakpointsActive + * - Console output comes via Console.messageAdded instead of Runtime.consoleAPICalled + * + * It implements a minimal subset of DAP to support basic TypeScript debugging: + * - Setting breakpoints + * - Stepping through code (step in, step over, step out, continue) + * - Inspecting variables and stack frames + * - Evaluating expressions + * + * Usage: + * bun run dap_websocket_server_bun.ts [--port PORT] [--host HOST] + */ + +import { spawn, type Subprocess } from 'bun' +import { mkdtemp, writeFile, unlink, rmdir, symlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// Types for V8 Inspector Protocol +interface V8Message { + id?: number + method?: string + params?: Record + result?: Record + error?: { message: string; code?: number } +} + +interface V8CallFrame { + callFrameId: string + functionName: string + location: { + scriptId: string + lineNumber: number + columnNumber: number + } + scopeChain: Array<{ + type: string + object: { objectId?: string } + name?: string + }> + this?: { objectId?: string } +} + +interface V8Script { + scriptId: string + url: string + startLine: number + startColumn: number + endLine: number + endColumn: number + hash: string + sourceMapURL?: string +} + +// DAP Message types +interface DAPMessage { + seq: number + type: 'request' | 'response' | 'event' + command?: string + event?: string + request_seq?: number + success?: boolean + message?: string + body?: Record + arguments?: Record +} + +interface StackFrame { + id: number + name: string + source: { path: string; name: string } + line: number + column: number +} + +interface Variable { + name: string + value: string + type: string + variablesReference: number +} + +interface Breakpoint { + id: number + verified: boolean + line: number + source?: { path: string; name?: string } +} + +// Logging +const LOG_LEVEL = process.env.LOG_LEVEL || 'DEBUG' +const logger = { + debug: (...args: unknown[]) => { + if (LOG_LEVEL === 'DEBUG') console.log('[DEBUG]', new Date().toISOString(), ...args) + }, + info: (...args: unknown[]) => console.log('[INFO]', new Date().toISOString(), ...args), + warn: (...args: unknown[]) => console.warn('[WARN]', new Date().toISOString(), ...args), + error: (...args: unknown[]) => console.error('[ERROR]', new Date().toISOString(), ...args) +} + +// JWT verification for debug requests +// The debugger fetches the public key from the Windmill backend's JWKS endpoint +const WINDMILL_BASE_URL = process.env.WINDMILL_BASE_URL || process.env.BASE_INTERNAL_URL // e.g., http://localhost:8000 +const REQUIRE_SIGNED_REQUESTS = process.env.REQUIRE_SIGNED_DEBUG_REQUESTS !== 'false' + +interface JWK { + kty: string + crv: string + x: string + kid: string + use: string + alg: string +} + +interface JWKS { + keys: JWK[] +} + +interface DebugTokenClaims { + code_hash: string + language: string + workspace_id: string + email: string + iat: number + exp: number + job_id: string +} + +interface ExpressionTokenClaims { + expression_hash: string + job_id: string + workspace_id: string + email: string + iat: number + exp: number +} + +// Cached public key +let cachedPublicKey: CryptoKey | null = null +let publicKeyFetchPromise: Promise | null = null + +/** + * Fetch and cache the Ed25519 public key from the JWKS endpoint. + */ +async function getPublicKey(): Promise { + if (cachedPublicKey) { + return cachedPublicKey + } + + if (publicKeyFetchPromise) { + return publicKeyFetchPromise + } + + if (!WINDMILL_BASE_URL) { + logger.warn('WINDMILL_BASE_URL not set - cannot fetch public key') + return null + } + + publicKeyFetchPromise = (async () => { + try { + const jwksUrl = `${WINDMILL_BASE_URL.replace(/\/$/, '')}/api/debug/jwks` + logger.info(`Fetching JWKS from ${jwksUrl}`) + const response = await fetch(jwksUrl) + if (!response.ok) { + throw new Error(`Failed to fetch JWKS: ${response.status} ${response.statusText}`) + } + + const jwks: JWKS = await response.json() + if (!jwks.keys || jwks.keys.length === 0) { + throw new Error('No keys in JWKS') + } + + const jwk = jwks.keys[0] + if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') { + throw new Error(`Unsupported key type: ${jwk.kty}/${jwk.crv}`) + } + + // Decode the public key from base64url + const publicKeyBytes = Uint8Array.from(atob(jwk.x.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)) + + // Import as Ed25519 public key + const key = await crypto.subtle.importKey( + 'raw', + publicKeyBytes, + { name: 'Ed25519' }, + true, + ['verify'] + ) + + cachedPublicKey = key + logger.info('Successfully loaded Ed25519 public key from JWKS') + return key + } catch (error) { + logger.error(`Failed to fetch/parse JWKS: ${error}`) + return null + } finally { + publicKeyFetchPromise = null + } + })() + + return publicKeyFetchPromise +} + +/** + * Compute SHA-256 hash of code and return first 16 bytes as hex. + */ +async function computeCodeHash(code: string): Promise { + const encoder = new TextEncoder() + const data = encoder.encode(code) + const hashBuffer = await crypto.subtle.digest('SHA-256', data) + const hashArray = new Uint8Array(hashBuffer) + return Array.from(hashArray.slice(0, 16)) + .map(b => b.toString(16).padStart(2, '0')) + .join('') +} + +/** + * Base64url decode + */ +function base64urlDecode(str: string): Uint8Array { + // Add padding if needed + const padding = '='.repeat((4 - str.length % 4) % 4) + const base64 = str.replace(/-/g, '+').replace(/_/g, '/') + padding + const binary = atob(base64) + return Uint8Array.from(binary, c => c.charCodeAt(0)) +} + +/** + * Verify a JWT debug token. + * Returns null if valid, or an error message if invalid. + */ +async function verifyDebugToken(token: string, code: string): Promise { + const publicKey = await getPublicKey() + + if (!publicKey) { + if (REQUIRE_SIGNED_REQUESTS) { + return 'Public key not available but signed requests are required. Set WINDMILL_BASE_URL.' + } + logger.warn('Public key not available - signature verification disabled') + return null + } + + // Parse JWT + const parts = token.split('.') + if (parts.length !== 3) { + return 'Invalid JWT format' + } + + const [headerB64, claimsB64, signatureB64] = parts + + try { + // Verify signature + const message = new TextEncoder().encode(`${headerB64}.${claimsB64}`) + const signature = base64urlDecode(signatureB64) + + const isValid = await crypto.subtle.verify( + { name: 'Ed25519' }, + publicKey, + signature, + message + ) + + if (!isValid) { + return 'Invalid JWT signature' + } + + // Parse and validate claims + const claimsJson = new TextDecoder().decode(base64urlDecode(claimsB64)) + const claims: DebugTokenClaims = JSON.parse(claimsJson) + + // Check expiration + const now = Math.floor(Date.now() / 1000) + if (now > claims.exp) { + return `Token expired: ${now - claims.exp} seconds ago` + } + + // Verify code hash + const expectedHash = await computeCodeHash(code) + if (claims.code_hash !== expectedHash) { + return 'Code hash mismatch - code was modified after signing' + } + + logger.info(`Verified debug token from ${claims.email} in workspace ${claims.workspace_id} (job: ${claims.job_id})`) + return null + } catch (error) { + return `JWT verification error: ${error}` + } +} + +/** + * Compute SHA-256 hash of an expression and return first 16 bytes as hex. + */ +async function computeExpressionHash(expression: string): Promise { + const encoder = new TextEncoder() + const data = encoder.encode(expression) + const hashBuffer = await crypto.subtle.digest('SHA-256', data) + const hashArray = new Uint8Array(hashBuffer) + return Array.from(hashArray.slice(0, 16)) + .map(b => b.toString(16).padStart(2, '0')) + .join('') +} + +/** + * Verify a JWT expression token. + * Returns null if valid, or an error message if invalid. + * Note: Expression tokens are optional - if not provided, the expression is still evaluated + * but just not audit logged. This maintains backwards compatibility. + */ +async function verifyExpressionToken(token: string, expression: string): Promise { + const publicKey = await getPublicKey() + + if (!publicKey) { + // Expression tokens are optional - don't block if public key unavailable + logger.debug('Public key not available - skipping expression token verification') + return null + } + + // Parse JWT + const parts = token.split('.') + if (parts.length !== 3) { + return 'Invalid JWT format' + } + + const [headerB64, claimsB64, signatureB64] = parts + + try { + // Verify signature + const message = new TextEncoder().encode(`${headerB64}.${claimsB64}`) + const signature = base64urlDecode(signatureB64) + + const isValid = await crypto.subtle.verify( + { name: 'Ed25519' }, + publicKey, + signature, + message + ) + + if (!isValid) { + return 'Invalid expression token signature' + } + + // Parse and validate claims + const claimsJson = new TextDecoder().decode(base64urlDecode(claimsB64)) + const claims: ExpressionTokenClaims = JSON.parse(claimsJson) + + // Check expiration + const now = Math.floor(Date.now() / 1000) + if (now > claims.exp) { + return `Expression token expired: ${now - claims.exp} seconds ago` + } + + // Verify expression hash + const expectedHash = await computeExpressionHash(expression) + if (claims.expression_hash !== expectedHash) { + return 'Expression hash mismatch - expression was modified after signing' + } + + logger.info(`Verified expression token from ${claims.email} in workspace ${claims.workspace_id} (job: ${claims.job_id})`) + return null + } catch (error) { + return `Expression token verification error: ${error}` + } +} + +/** + * Remove version specifiers from import statements. + * Transforms `import x from "package@1.2.3"` to `import x from "package"` + * This matches the behavior of the Windmill backend's remove_pinned_imports function. + */ +function removePinnedImports(code: string): string { + // Regex to match import specifiers with version pins + // Handles both scoped packages (@scope/package@version) and regular packages (package@version) + // Group 1: package name (including scope if present) + // The @version part is matched but not captured + // Group 2: any path suffix after the package (e.g., /subpath) + const IMPORTS_VERSION = /^((?:@[^/@]+\/[^/@]+)|(?:[^/@]+))(?:@[^/]+)?(.*)$/ + + // Find all string literals that look like imports (in quotes) + // This regex finds strings in import/export statements + const importRegex = /(?:import|export).*?from\s+['"]([^'"]+)['"]/g + + let result = code + let match: RegExpExecArray | null + + // Collect all imports first to avoid issues with overlapping replacements + const imports: string[] = [] + while ((match = importRegex.exec(code)) !== null) { + imports.push(match[1]) + } + + // Sort by length descending to handle longer matches first (avoids partial replacements) + imports.sort((a, b) => b.length - a.length) + + // Process each import and remove version specifiers + for (const importPath of imports) { + const versionMatch = IMPORTS_VERSION.exec(importPath) + if (versionMatch) { + const packageName = versionMatch[1] + const pathSuffix = versionMatch[2] || '' + const newImportPath = packageName + pathSuffix + + // Only replace if we actually removed a version + if (newImportPath !== importPath) { + // Replace all occurrences of this import in the code + result = result.split(`"${importPath}"`).join(`"${newImportPath}"`) + result = result.split(`'${importPath}'`).join(`'${newImportPath}'`) + logger.debug(`Removed version from import: "${importPath}" -> "${newImportPath}"`) + } + } + } + + return result +} + +// Nsjail configuration for sandboxed execution +export interface NsjailConfig { + enabled: boolean + binaryPath: string + configPath?: string + extraArgs?: string[] +} + +/** + * VLQ (Variable-Length Quantity) decoder for source maps. + * Returns array of decoded integers from VLQ string. + */ +function decodeVLQ(encoded: string): number[] { + const VLQ_BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + const values: number[] = [] + let shift = 0 + let value = 0 + + for (const char of encoded) { + const digit = VLQ_BASE64.indexOf(char) + if (digit === -1) continue + + const hasContinuation = digit & 32 + value += (digit & 31) << shift + + if (hasContinuation) { + shift += 5 + } else { + const isNegative = value & 1 + value = value >> 1 + values.push(isNegative ? -value : value) + value = 0 + shift = 0 + } + } + return values +} + +/** + * Source map line mappings - both directions. + */ +interface SourceMapMappings { + // original 0-indexed line -> transpiled 0-indexed line (for setting breakpoints) + originalToTranspiled: Map + // transpiled 0-indexed line -> original 0-indexed line (for reporting positions) + transpiledToOriginal: Map +} + +/** + * Parse source map and build bidirectional line mapping. + * Returns Maps for both original->transpiled and transpiled->original mappings. + * + * Important: When multiple original lines map to the same transpiled line (common with + * TypeScript transpilation), we store the LAST/HIGHEST original line for the reverse + * mapping. This is because Bun compresses multi-line function signatures, but the + * actual statement being executed typically corresponds to the highest original line + * that maps to that transpiled line. + */ +function parseSourceMapLineMapping(sourceMapUrl: string): SourceMapMappings { + const mappings: SourceMapMappings = { + originalToTranspiled: new Map(), + transpiledToOriginal: new Map() + } + + try { + // Extract base64 content from data URL + const match = sourceMapUrl.match(/^data:application\/json;base64,(.+)$/) + if (!match) { + logger.warn('Source map is not a base64 data URL') + return mappings + } + + const decoded = atob(match[1]) + const sourceMap = JSON.parse(decoded) as { + mappings: string + sources?: string[] + sourcesContent?: string[] + } + + if (!sourceMap.mappings) { + return mappings + } + + // Parse VLQ mappings + // Each line in transpiled code is separated by ';' + // Each segment within a line is separated by ',' + // Segment format: [transpiled column, source index, original line, original column, name index] + const lines = sourceMap.mappings.split(';') + let originalLine = 0 + + for (let transpiledLine = 0; transpiledLine < lines.length; transpiledLine++) { + const segments = lines[transpiledLine].split(',') + + for (const segment of segments) { + if (!segment) continue + + const values = decodeVLQ(segment) + if (values.length >= 3) { + // values[2] is the delta for original line + originalLine += values[2] + + // Map this original line to this transpiled line (for setting breakpoints) + // Only store the first occurrence (first transpiled line for this original line) + // This ensures breakpoints are set at the earliest transpiled line containing the original code + if (!mappings.originalToTranspiled.has(originalLine)) { + mappings.originalToTranspiled.set(originalLine, transpiledLine) + logger.debug(`Source map: original line ${originalLine} -> transpiled line ${transpiledLine}`) + } + + // Map transpiled line to original line (for reporting positions) + // ALWAYS update to store the HIGHEST original line for each transpiled line. + // This is critical because when Bun compresses code (e.g., multi-line function + // params into one line), the actual statement being executed corresponds to + // the highest original line that maps to that transpiled line. + // Example: transpiled line 3 might map to original lines 6 and 7; + // when stopped on transpiled line 3, we want to report line 7 (the return statement) + // not line 6 (the console.log that happened earlier). + const existing = mappings.transpiledToOriginal.get(transpiledLine) + if (existing === undefined || originalLine > existing) { + mappings.transpiledToOriginal.set(transpiledLine, originalLine) + logger.debug(`Source map: transpiled line ${transpiledLine} -> original line ${originalLine}`) + } + } + } + } + } catch (error) { + logger.error('Failed to parse source map:', error) + } + + return mappings +} + +/** + * Manages a debug session with a Bun subprocess. + */ +export class DebugSession { + private ws: WebSocket + private seq = 1 + private initialized = false + private configured = false + private running = false + private terminatedSent = false + + // Bun subprocess + private process: Subprocess | null = null + private inspectorWs: WebSocket | null = null + private inspectorSeq = 1 + private pendingInspectorRequests = new Map< + number, + { resolve: (value: V8Message) => void; reject: (error: Error) => void } + >() + + // Script and breakpoint tracking + private scriptPath: string | null = null + private tempDir: string | null = null + private tempFile: string | null = null + private breakpoints = new Map() // file -> line numbers + private breakpointIds = new Map() // file -> V8 breakpoint IDs + + // Script ID mapping (V8 uses script IDs, we need to map to file paths) + private scripts = new Map() + private mainScriptId: string | null = null + + // Source map line mappings for both directions + // This is needed because Bun transpiles TypeScript and may strip blank lines/comments + private sourceMapMappings: SourceMapMappings = { + originalToTranspiled: new Map(), + transpiledToOriginal: new Map() + } + + // Call frames when paused + private callFrames: V8CallFrame[] = [] + private variablesRefCounter = 1 + private scopesMap = new Map() + private objectsMap = new Map() // variablesRef -> objectId + + // For calling main() + private callMain = false + private mainArgs: Record = {} + + // Buffer console output during stepping to ensure correct order + private isStepping = false + private pendingConsoleOutput: Array> = [] + + // Captured result from main() execution + private scriptResult: unknown = undefined + + // Track if current pause is due to a breakpoint (for line number correction) + private pausedAtBreakpoint = false + + // Environment variables to pass to the debugger subprocess (e.g., WM_WORKSPACE, WM_TOKEN, etc.) + private envVars: Record = {} + + // Nsjail configuration for sandboxed execution + private nsjailConfig?: NsjailConfig + + // Custom bun binary path (can be overridden) + private bunPath: string = 'bun' + + // Windmill binary path for prepare-deps CLI (optional, for dependency installation) + private windmillPath?: string + + // Path to installed node_modules (set after prepare-deps runs) + private nodeModulesPath?: string + + constructor(ws: WebSocket, options?: { nsjailConfig?: NsjailConfig; bunPath?: string; windmillPath?: string }) { + this.ws = ws + this.nsjailConfig = options?.nsjailConfig + this.bunPath = options?.bunPath || 'bun' + this.windmillPath = options?.windmillPath + } + + private nextSeq(): number { + return this.seq++ + } + + private nextInspectorSeq(): number { + return this.inspectorSeq++ + } + + private nextVarRef(): number { + return this.variablesRefCounter++ + } + + /** + * Send a DAP message to the client. + */ + private sendMessage(msg: DAPMessage): void { + const data = JSON.stringify(msg) + logger.debug('Sending DAP:', data) + this.ws.send(data) + } + + /** + * Send a DAP response. + */ + private sendResponse( + request: DAPMessage, + success = true, + body: Record = {}, + message = '' + ): void { + this.sendMessage({ + seq: this.nextSeq(), + type: 'response', + command: request.command || '', + request_seq: request.seq, + success, + message, + body + }) + } + + /** + * Send a DAP event. + */ + private sendEvent(event: string, body: Record = {}): void { + this.sendMessage({ + seq: this.nextSeq(), + type: 'event', + event, + body + }) + } + + /** + * Send a V8 Inspector command and wait for response. + */ + private async sendInspectorCommand( + method: string, + params: Record = {} + ): Promise { + if (!this.inspectorWs || this.inspectorWs.readyState !== WebSocket.OPEN) { + throw new Error('Inspector not connected') + } + + const id = this.nextInspectorSeq() + const message = { id, method, params } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pendingInspectorRequests.delete(id) + reject(new Error(`Inspector command timeout: ${method}`)) + }, 10000) + + this.pendingInspectorRequests.set(id, { + resolve: (value) => { + clearTimeout(timeout) + resolve(value) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + } + }) + + logger.debug('Sending to inspector:', JSON.stringify(message)) + this.inspectorWs!.send(JSON.stringify(message)) + }) + } + + /** + * Handle messages from the V8 Inspector. + */ + private handleInspectorMessage(data: string): void { + try { + const message: V8Message = JSON.parse(data) + logger.debug('Inspector message:', data) + + // Handle responses + if (message.id !== undefined) { + const pending = this.pendingInspectorRequests.get(message.id) + if (pending) { + this.pendingInspectorRequests.delete(message.id) + if (message.error) { + pending.reject(new Error(message.error.message)) + } else { + pending.resolve(message) + } + } + return + } + + // Handle events + if (message.method) { + this.handleInspectorEvent(message) + } + } catch (error) { + logger.error('Failed to parse inspector message:', error) + } + } + + /** + * Handle V8 Inspector events. + */ + private handleInspectorEvent(message: V8Message): void { + const params = message.params || {} + + switch (message.method) { + case 'Debugger.scriptParsed': + this.handleScriptParsed(params as unknown as V8Script) + break + + case 'Debugger.paused': + this.handlePaused(params) + break + + case 'Debugger.resumed': + this.sendEvent('continued', { threadId: 1 }) + break + + case 'Runtime.consoleAPICalled': + this.handleConsoleOutput(params) + break + + case 'Console.messageAdded': + // Bun's WebKit Inspector uses Console.messageAdded instead of Runtime.consoleAPICalled + this.handleConsoleMessageAdded(params) + break + + case 'Runtime.exceptionThrown': + this.handleException(params) + break + + case 'Debugger.breakpointResolved': + logger.info(`Breakpoint resolved: ${JSON.stringify(params)}`) + break + + default: + logger.debug('Unhandled inspector event:', message.method) + } + } + + /** + * Handle script parsed event - track script IDs and parse source maps. + */ + private handleScriptParsed(script: V8Script): void { + this.scripts.set(script.scriptId, script) + + // Identify the main script + if (script.url && this.scriptPath && script.url.endsWith(this.scriptPath.split('/').pop()!)) { + this.mainScriptId = script.scriptId + logger.info(`Main script parsed: ${script.url} (ID: ${script.scriptId})`) + + // Parse source map to get bidirectional line mappings + // This is crucial because Bun transpiles TypeScript and may strip blank lines/comments + if (script.sourceMapURL) { + logger.info('Parsing source map for line number mapping...') + this.sourceMapMappings = parseSourceMapLineMapping(script.sourceMapURL) + logger.info(`Source map parsed: ${this.sourceMapMappings.originalToTranspiled.size} original->transpiled, ${this.sourceMapMappings.transpiledToOriginal.size} transpiled->original`) + } + } + } + + /** + * Apply breakpoints using URL pattern (works before script is parsed). + * This is called before the script starts running. + */ + private async applyBreakpointsByUrl(): Promise { + logger.info(`applyBreakpointsByUrl called. scriptPath: ${this.scriptPath}, breakpoints: ${JSON.stringify([...this.breakpoints.entries()])}`) + if (!this.scriptPath) { + logger.warn('No scriptPath set, skipping breakpoints') + return + } + + // Clear existing breakpoints first + for (const [filePath, ids] of this.breakpointIds) { + for (const id of ids) { + try { + await this.sendInspectorCommand('Debugger.removeBreakpoint', { breakpointId: id }) + } catch { + // Ignore errors during removal + } + } + } + this.breakpointIds.clear() + + // Set breakpoints by URL pattern + for (const [filePath, lines] of this.breakpoints) { + logger.info(`Setting breakpoints for ${filePath}: lines ${lines}`) + const ids: string[] = [] + for (const line of lines) { + try { + // Use the actual script path as URL regex + // For file URLs, we match the end of the path + const urlRegex = this.scriptPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + + // Line number conversion: + // - User line N (1-indexed from DAP/Monaco) + // - After debugger injection at line 0: user line N -> temp file line N (0-indexed) + // - But Bun transpiles TypeScript and may strip blank lines/comments! + // - We need to use the source map to find the correct transpiled line + // + // The source map maps original 0-indexed lines to transpiled 0-indexed lines. + // Original line 0 is the injected debugger statement. + // User line N (1-indexed) in original = line N (0-indexed) in temp file after injection. + const originalLine0Indexed = line // After injection, user line N = temp file line N (0-indexed) + + // Look up the transpiled line from the source map + let transpiledLine = originalLine0Indexed + if (this.sourceMapMappings.originalToTranspiled.size > 0) { + const mappedLine = this.sourceMapMappings.originalToTranspiled.get(originalLine0Indexed) + if (mappedLine !== undefined) { + transpiledLine = mappedLine + logger.info(`Source map: original line ${originalLine0Indexed} -> transpiled line ${transpiledLine}`) + } else { + // If not found in source map, try to find the nearest mapped line + // This handles cases where the exact line isn't in the map (comments, blank lines) + logger.warn(`Line ${originalLine0Indexed} not in source map, using as-is`) + } + } + + logger.info(`Setting breakpoint: user line ${line} -> original line ${originalLine0Indexed} (0-idx) -> transpiled line ${transpiledLine}`) + const response = await this.sendInspectorCommand('Debugger.setBreakpointByUrl', { + lineNumber: transpiledLine, + urlRegex, + columnNumber: 0 + }) + logger.info(`Breakpoint response: ${JSON.stringify(response)}`) + if (response.result?.breakpointId) { + ids.push(response.result.breakpointId as string) + logger.info(`Breakpoint set at transpiled line ${transpiledLine} (user line ${line}, ID: ${response.result.breakpointId})`) + } else { + logger.warn(`No breakpointId in response for line ${line}`) + } + } catch (error) { + logger.error(`Failed to set breakpoint at line ${line}:`, error) + } + } + this.breakpointIds.set(filePath, ids) + } + logger.info(`Finished setting breakpoints. Total IDs: ${[...this.breakpointIds.values()].flat().length}`) + } + + // Track if this is the initial pause from our injected debugger statement + private initialPauseDone = false + + /** + * Handle paused event. + */ + private async handlePaused(params: Record): Promise { + const callFrames = params.callFrames as V8CallFrame[] | undefined + const reason = params.reason as string + + logger.info(`handlePaused called: reason=${reason}, lineNumber=${callFrames?.[0]?.location?.lineNumber}, scriptId=${callFrames?.[0]?.location?.scriptId}`) + + if (callFrames) { + this.callFrames = callFrames + } + + // Check if this is the initial pause from our injected debugger statement + // We detect this by checking if we're on line 0 (our injected line) and reason is DebuggerStatement + const isInitialPause = + !this.initialPauseDone && + reason === 'DebuggerStatement' && + callFrames && + callFrames.length > 0 && + callFrames[0].location.lineNumber === 0 + + logger.info(`isInitialPause=${isInitialPause}, initialPauseDone=${this.initialPauseDone}`) + + if (isInitialPause) { + this.initialPauseDone = true + logger.info('Initial pause from injected debugger statement') + + // Re-apply breakpoints now that the script is fully parsed + // This ensures breakpoints resolve to actual code locations + logger.info('Re-applying breakpoints after script is parsed...') + await this.applyBreakpointsByUrl() + + // Auto-resume to continue to actual user code + try { + await this.sendInspectorCommand('Debugger.resume', {}) + logger.info('Auto-resumed after initial pause') + } catch (error) { + logger.error('Failed to auto-resume:', error) + } + return // Don't send stopped event for the initial debugger pause + } + + // Mark initial pause as done if we get here + this.initialPauseDone = true + + // Map reason to DAP reason + let dapReason: string + switch (reason) { + case 'Breakpoint': + case 'breakpoint': + dapReason = 'breakpoint' + break + case 'DebuggerStatement': + dapReason = 'breakpoint' // Treat user's debugger; statements as breakpoints + break + case 'step': + dapReason = 'step' + break + case 'exception': + dapReason = 'exception' + break + case 'debugCommand': + dapReason = 'pause' + break + default: + dapReason = 'step' + } + + // Get current line from first call frame + // lineNumber is 0-indexed in WebKit inspector (transpiled line) + // We need to convert it back to the original user line using the source map + let line: number | undefined + if (callFrames && callFrames.length > 0) { + const transpiledLine = callFrames[0].location.lineNumber + // Convert transpiled line back to original line using source map + if (this.sourceMapMappings.transpiledToOriginal.size > 0) { + const originalLine = this.sourceMapMappings.transpiledToOriginal.get(transpiledLine) + if (originalLine !== undefined) { + line = originalLine + logger.info(`Stopped: transpiled line ${transpiledLine} -> original line ${line}`) + } else { + // If not found in reverse map, use transpiled line as-is + line = transpiledLine + logger.warn(`Transpiled line ${transpiledLine} not in reverse source map, using as-is`) + } + } else { + line = transpiledLine + } + } + + this.sendEvent('stopped', { + reason: dapReason, + threadId: 1, + allThreadsStopped: true, + line + }) + + // Flush any buffered console output that occurred during stepping + this.isStepping = false + for (const outputEvent of this.pendingConsoleOutput) { + this.sendEvent('output', outputEvent) + } + this.pendingConsoleOutput = [] + } + + /** + * Handle console output (V8/Chrome format). + */ + private handleConsoleOutput(params: Record): void { + const type = params.type as string + const args = params.args as Array<{ type: string; value?: unknown; description?: string }> + + if (!args) return + + const output = args + .map((arg) => { + if (arg.value !== undefined) return String(arg.value) + if (arg.description) return arg.description + return '' + }) + .join(' ') + + this.sendEvent('output', { + category: type === 'error' ? 'stderr' : 'stdout', + output: output + '\n' + }) + } + + /** + * Format a console parameter for output. + * Handles primitives, objects with preview, arrays, etc. + */ + private formatConsoleParameter(p: { + type: string + value?: unknown + description?: string + subtype?: string + className?: string + objectId?: string + preview?: { + type: string + subtype?: string + description?: string + overflow?: boolean + properties?: Array<{ name: string; type: string; value?: string; subtype?: string }> + } + }): string { + // Handle primitives with direct values + if (p.value !== undefined) { + if (typeof p.value === 'string') return p.value + return String(p.value) + } + + // Handle null + if (p.subtype === 'null') return 'null' + + // Handle undefined + if (p.type === 'undefined') return 'undefined' + + // Handle objects with preview (this is the key fix for process.env, etc.) + if (p.type === 'object' && p.preview && p.preview.properties) { + const props = p.preview.properties + const isArray = p.subtype === 'array' || p.preview.subtype === 'array' + + if (isArray) { + // Format as array: [val1, val2, ...] + const items = props.map((prop) => this.formatPreviewValue(prop)) + const suffix = p.preview.overflow ? ', ...' : '' + return `[${items.join(', ')}${suffix}]` + } else { + // Format as object: { key1: val1, key2: val2, ... } + const items = props.map((prop) => `${prop.name}: ${this.formatPreviewValue(prop)}`) + const suffix = p.preview.overflow ? ', ...' : '' + return `{ ${items.join(', ')}${suffix} }` + } + } + + // Handle functions + if (p.type === 'function') { + return p.description || '[Function]' + } + + // Fallback to description (for other object types without preview) + if (p.description) return p.description + + return '' + } + + /** + * Format a single property from an object preview. + */ + private formatPreviewValue(prop: { name: string; type: string; value?: string; subtype?: string }): string { + if (prop.subtype === 'null') return 'null' + if (prop.type === 'undefined') return 'undefined' + if (prop.type === 'string') return `"${prop.value ?? ''}"` + if (prop.type === 'number' || prop.type === 'boolean') return prop.value ?? '' + if (prop.type === 'function') return '[Function]' + if (prop.type === 'object') { + // Nested objects just show their type/value preview + if (prop.subtype === 'array') return prop.value || '[]' + return prop.value || '{...}' + } + return prop.value ?? '' + } + + /** + * Handle console output (WebKit/Bun format - Console.messageAdded). + */ + private handleConsoleMessageAdded(params: Record): void { + const message = params.message as { + source?: string + level?: string + text?: string + type?: string + line?: number + column?: number + url?: string + parameters?: Array<{ + type: string + value?: unknown + description?: string + subtype?: string + className?: string + objectId?: string + preview?: { + type: string + subtype?: string + description?: string + overflow?: boolean + properties?: Array<{ name: string; type: string; value?: string; subtype?: string }> + } + }> + } + + if (!message) return + + let output: string + if (message.parameters && message.parameters.length > 0) { + output = message.parameters + .map((p) => this.formatConsoleParameter(p)) + .join(' ') + } else { + output = message.text || '' + } + + // Check for __WINDMILL_RESULT__ prefix and capture the result + if (output.startsWith('__WINDMILL_RESULT__:')) { + try { + const resultJson = output.substring('__WINDMILL_RESULT__:'.length) + this.scriptResult = JSON.parse(resultJson) + logger.info(`Captured script result: ${resultJson}`) + + // Flush any pending console output before sending terminated + // (output may have been buffered during stepping/continue) + this.isStepping = false + for (const pendingOutput of this.pendingConsoleOutput) { + this.sendEvent('output', pendingOutput) + } + this.pendingConsoleOutput = [] + + // Send terminated event immediately after capturing result + // This is more reliable than waiting for process.exited or inspector.onclose + if (!this.terminatedSent) { + this.terminatedSent = true + this.running = false + logger.info('Sending terminated event after result capture') + this.sendEvent('terminated', { result: this.scriptResult }) + } + } catch (error) { + logger.error('Failed to parse script result:', error) + } + // Don't send this as output to the client + return + } + + const category = message.level === 'error' || message.level === 'warning' ? 'stderr' : 'stdout' + + // Build the output event with optional source location + const outputEvent: Record = { + category, + output: output + '\n' + } + + // If line info is available, include source reference + // Line numbers from WebKit are 0-indexed, but we have injected debugger at line 0 + // So inspector line N = user line N (same as breakpoint handling) + if (message.line !== undefined && message.url) { + outputEvent.source = { + path: message.url, + name: message.url.split('/').pop() || 'script.ts' + } + outputEvent.line = message.line // 0-indexed + 1 - 1 for injected debugger = same + if (message.column !== undefined) { + outputEvent.column = message.column + 1 + } + } + + // Buffer output during stepping to ensure it appears after the stopped event + if (this.isStepping) { + this.pendingConsoleOutput.push(outputEvent) + } else { + this.sendEvent('output', outputEvent) + } + } + + /** + * Handle runtime exceptions. + */ + private handleException(params: Record): void { + const exceptionDetails = params.exceptionDetails as { + text?: string + exception?: { description?: string } + } + + if (exceptionDetails) { + const message = + exceptionDetails.exception?.description || exceptionDetails.text || 'Unknown exception' + this.sendEvent('output', { + category: 'stderr', + output: `Exception: ${message}\n` + }) + } + } + + /** + * Handle the 'initialize' request. + */ + async handleInitialize(request: DAPMessage): Promise { + const 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 + } + + this.sendResponse(request, true, capabilities) + this.initialized = true + this.sendEvent('initialized') + } + + /** + * Handle the 'setBreakpoints' request. + */ + async handleSetBreakpoints(request: DAPMessage): Promise { + const args = request.arguments || {} + const source = args.source as { path?: string } | undefined + const sourcePath = source?.path || '' + const breakpointsData = (args.breakpoints as Array<{ line: number }>) || [] + + const verifiedBreakpoints: Breakpoint[] = [] + const lineNumbers: number[] = [] + + for (const bp of breakpointsData) { + const line = bp.line + lineNumbers.push(line) + verifiedBreakpoints.push({ + id: verifiedBreakpoints.length + 1, + verified: true, + line, + source: { path: sourcePath } + }) + } + + // Store breakpoints + this.breakpoints.set(sourcePath, lineNumbers) + logger.info(`Stored breakpoints at lines ${lineNumbers} for ${sourcePath}`) + + // If inspector is connected, apply breakpoints immediately + if (this.inspectorWs) { + await this.applyBreakpointsByUrl() + } + + this.sendResponse(request, true, { breakpoints: verifiedBreakpoints }) + } + + /** + * Handle the 'configurationDone' request. + */ + async handleConfigurationDone(request: DAPMessage): Promise { + this.configured = true + this.sendResponse(request) + } + + /** + * Handle the 'launch' request. + */ + async handleLaunch(request: DAPMessage): Promise { + const args = request.arguments || {} + let code = args.code as string | undefined + this.scriptPath = args.program as string | undefined + const cwd = (args.cwd as string) || process.cwd() + this.callMain = (args.callMain as boolean) || false + this.mainArgs = (args.args as Record) || {} + this.envVars = (args.env as Record) || {} + + // Verify JWT token if code is provided (signed debug request) + // The token is passed in the launch arguments + if (code && REQUIRE_SIGNED_REQUESTS) { + const token = args.token as string | undefined + if (!token) { + logger.error('No debug token provided but signed requests are required') + this.sendResponse(request, false, {}, 'Debug token required. Ensure the debug session was signed by the backend.') + return + } + + const verificationError = await verifyDebugToken(token, code) + if (verificationError) { + logger.error(`Token verification failed: ${verificationError}`) + this.sendResponse(request, false, {}, `Token verification failed: ${verificationError}`) + return + } + } + + // If BASE_INTERNAL_URL is set on the server, use it to override WM_BASE_URL + if (process.env.BASE_INTERNAL_URL) { + this.envVars.WM_BASE_URL = process.env.BASE_INTERNAL_URL + } + + if (Object.keys(this.envVars).length > 0) { + logger.info(`Launch with env vars: ${Object.keys(this.envVars).join(', ')}`) + } + + if (!this.scriptPath && !code) { + this.sendResponse(request, false, {}, 'No program or code specified') + return + } + + // Prepare dependencies using the original code (before any modifications) + // This analyzes imports and installs required npm packages + if (code) { + this.nodeModulesPath = await this.prepareDependencies(code) || undefined + + // Remove version specifiers from imports (e.g., "lodash@4" -> "lodash") + // This must happen AFTER prepareDependencies (which needs the versions) + // but BEFORE the code is executed (Bun doesn't understand @version syntax) + code = removePinnedImports(code) + } + + // Reset state for new launch + this.initialPauseDone = false + + // Inject a debugger statement at the beginning to force initial pause + // This gives us a known pause point where we can ensure breakpoints are set + code = `debugger; // Auto-injected by Windmill debugger\n${code || ''}` + + // If callMain is true, append a call to main() with the provided args + if (this.callMain && code) { + // Generate positional arguments list (like Python's keyword args) + // For TypeScript, we pass arguments in order + const argsValues = Object.values(this.mainArgs) + .map((v) => JSON.stringify(v)) + .join(', ') + code = + code + + `\n\n// Auto-generated call to main entrypoint\n` + + `globalThis.__windmill_result__ = await main(${argsValues});\n` + + `console.log("__WINDMILL_RESULT__:" + JSON.stringify(globalThis.__windmill_result__));\n` + + `// Small delay to ensure console output is delivered via inspector before process exits\n` + + `await new Promise(r => setTimeout(r, 50));\n` + logger.info(`Added main() call with args: ${argsValues}`) + } + + // Write code to temp file if provided + if (code && !this.scriptPath) { + try { + this.tempDir = await mkdtemp(join(tmpdir(), 'windmill_debug_')) + this.tempFile = join(this.tempDir, 'script.ts') + await writeFile(this.tempFile, code) + this.scriptPath = this.tempFile + logger.info(`Wrote code to ${this.tempFile}`) + // Log lines around breakpoint for debugging + const lines = code.split('\n') + for (let i = 24; i < Math.min(30, lines.length); i++) { + logger.info(` Line ${i} (0-idx) / ${i+1} (1-idx): ${lines[i]?.substring(0, 80)}`) + } + + // If we have installed node_modules, symlink them into the temp directory + // so Bun can find them when running the script + if (this.nodeModulesPath) { + const targetPath = join(this.tempDir, 'node_modules') + try { + await symlink(this.nodeModulesPath, targetPath) + logger.info(`Symlinked ${this.nodeModulesPath} -> ${targetPath}`) + } catch (symlinkError) { + logger.warn(`Failed to symlink node_modules: ${symlinkError}`) + // Don't fail the launch - try NODE_PATH as fallback + } + } + } catch (error) { + this.sendResponse(request, false, {}, `Failed to create temp file: ${error}`) + return + } + } + + this.sendResponse(request) + + // Start Bun with inspect-wait + try { + await this.startBunProcess(cwd) + } catch (error) { + this.sendEvent('output', { category: 'stderr', output: `Failed to start Bun: ${error}\n` }) + this.sendEvent('terminated', { error: String(error) }) + } + } + + // Store the inspector WebSocket URL when parsed from stderr + private inspectorWsUrl: string | null = null + private inspectorWsUrlPromise: { resolve: (url: string) => void; reject: (error: Error) => void } | null = null + + /** + * Prepare dependencies by calling the windmill CLI's prepare-deps command. + * This analyzes imports in the code and installs required npm packages. + * Returns the path to node_modules if any were installed. + */ + private async prepareDependencies(code: string, language: string = 'bun'): Promise { + if (!this.windmillPath) { + logger.info('No windmill binary path configured, skipping dependency preparation') + return null + } + + logger.info(`Preparing dependencies using ${this.windmillPath}`) + + try { + const input = JSON.stringify({ code, language }) + '\n' + logger.info(`prepare-deps input length: ${input.length}`) + + // Spawn the windmill binary with prepare-deps command + const proc = spawn({ + cmd: [this.windmillPath, 'prepare-deps'], + stdin: new Blob([input]), // Use Blob for complete stdin data + stdout: 'pipe', + stderr: 'pipe' + }) + + // Wait for completion + const output = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + logger.info(`prepare-deps output: ${output.substring(0, 200)}`) + logger.info(`prepare-deps stderr: ${stderr.substring(0, 200)}`) + + if (stderr) { + // Filter out the "Running in standalone mode" message + const filteredStderr = stderr.split('\n').filter(line => !line.includes('Running in standalone mode')).join('\n').trim() + if (filteredStderr) { + logger.debug(`prepare-deps stderr: ${filteredStderr}`) + } + } + + // Parse the JSON response + const lastLine = output.trim().split('\n').pop() || '' + if (!lastLine || !lastLine.startsWith('{')) { + // If output is empty or not JSON, the windmill binary likely failed to start + const errorMsg = stderr.trim() || 'windmill binary failed (no output)' + logger.error(`prepare-deps failed: ${errorMsg}`) + this.sendEvent('output', { + category: 'console', + output: `Warning: Failed to prepare dependencies: ${errorMsg}\n` + }) + return null + } + + const response = JSON.parse(lastLine) + + if (!response.success) { + const errorMsg = response.error || 'unknown error' + logger.error(`prepare-deps failed: ${errorMsg}`) + this.sendEvent('output', { + category: 'console', + output: `Warning: Failed to prepare dependencies: ${errorMsg}\n` + }) + return null + } + + if (response.node_modules_path) { + logger.info(`Dependencies installed at: ${response.node_modules_path}`) + this.sendEvent('output', { + category: 'console', + output: `Dependencies installed at: ${response.node_modules_path}\n` + }) + return response.node_modules_path + } + + logger.info('No external dependencies to install') + return null + } catch (error) { + logger.error(`Failed to prepare dependencies: ${error}`) + this.sendEvent('output', { + category: 'console', + output: `Warning: Failed to prepare dependencies: ${error}\n` + }) + return null + } + } + + /** + * Start the Bun subprocess with debugging enabled. + */ + private async startBunProcess(cwd: string): Promise { + if (!this.scriptPath) { + throw new Error('No script path') + } + + const inspectPort = 9229 + Math.floor(Math.random() * 1000) + const inspectUrl = `127.0.0.1:${inspectPort}` + + // Build the command - optionally wrapped with nsjail + let cmd: string[] = [this.bunPath, `--inspect-wait=${inspectUrl}`, this.scriptPath] + + if (this.nsjailConfig?.enabled) { + const nsjailCmd = [this.nsjailConfig.binaryPath] + + if (this.nsjailConfig.configPath) { + nsjailCmd.push('--config', this.nsjailConfig.configPath) + } + + if (this.nsjailConfig.extraArgs) { + nsjailCmd.push(...this.nsjailConfig.extraArgs) + } + + nsjailCmd.push('--cwd', cwd) + nsjailCmd.push('--') + nsjailCmd.push(...cmd) + + cmd = nsjailCmd + logger.info(`Starting Bun with nsjail: ${cmd.join(' ')}`) + } else { + logger.info(`Starting Bun with --inspect-wait=${inspectUrl}`) + } + + // Create a promise to wait for the WebSocket URL + const wsUrlPromise = new Promise((resolve, reject) => { + this.inspectorWsUrlPromise = { resolve, reject } + // Timeout after 10 seconds + setTimeout(() => { + if (this.inspectorWsUrlPromise) { + reject(new Error('Timeout waiting for inspector WebSocket URL')) + this.inspectorWsUrlPromise = null + } + }, 10000) + }) + + // Only include essential env vars + client-provided ones + // Don't inherit all of process.env to keep debugger environment clean + const envVars: Record = { + // Essential system vars + PATH: process.env.PATH || '/usr/bin:/bin', + HOME: process.env.HOME, + // Client-provided env vars (WM_WORKSPACE, WM_TOKEN, etc.) + // Note: WM_BASE_URL is already overridden by BASE_INTERNAL_URL if set + ...this.envVars + } + + // If we have installed node_modules, add NODE_PATH so Bun can find them + if (this.nodeModulesPath) { + envVars.NODE_PATH = this.nodeModulesPath + logger.info(`Setting NODE_PATH=${this.nodeModulesPath}`) + } + + this.process = spawn({ + cmd, + cwd, + stdout: 'pipe', + stderr: 'pipe', + env: envVars + }) + + // Note: We don't read stdout directly because Console.messageAdded events + // from the WebKit inspector handle console.log output. Reading stdout + // would cause duplicate output. + + // Handle stderr (look for inspector URL and forward non-inspector output) + this.readStderrForInspectorUrl(this.process.stderr) + + // Wait for the WebSocket URL to be parsed from stderr + const wsUrl = await wsUrlPromise + logger.info(`Got inspector WebSocket URL: ${wsUrl}`) + + // Connect to the inspector + await this.connectToInspector(wsUrl) + + // Wait for process to exit + this.process.exited.then(async (exitCode) => { + logger.info(`Bun process exited with code: ${exitCode}`) + this.running = false + + // Send terminated event BEFORE cleanup to ensure WebSocket is still open + if (!this.terminatedSent) { + this.terminatedSent = true + logger.info(`Sending terminated event with result: ${JSON.stringify(this.scriptResult)}`) + try { + this.sendEvent('terminated', this.scriptResult !== undefined ? { result: this.scriptResult } : {}) + logger.info('Terminated event sent successfully') + } catch (error) { + logger.error('Failed to send terminated event:', error) + } + } + + await this.cleanup() + }).catch((error) => { + logger.error('Error in process.exited handler:', error) + }) + } + + /** + * Read from a readable stream and send output events. + */ + private async readStream( + stream: ReadableStream | null, + category: 'stdout' | 'stderr' + ): Promise { + if (!stream) return + + const reader = stream.getReader() + const decoder = new TextDecoder() + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + const text = decoder.decode(value) + this.sendEvent('output', { category, output: text }) + } + } catch (error) { + logger.debug(`Stream ${category} ended:`, error) + } + } + + /** + * Read stderr specifically to extract the inspector WebSocket URL. + */ + private async readStderrForInspectorUrl(stream: ReadableStream | null): Promise { + if (!stream) return + + const reader = stream.getReader() + const decoder = new TextDecoder() + let buffer = '' + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + const text = decoder.decode(value) + buffer += text + + // Look for the WebSocket URL in Bun's inspector output + // Format: "ws://127.0.0.1:9229/xxxxx" + const wsMatch = buffer.match(/ws:\/\/[\d.]+:\d+\/[a-z0-9]+/i) + if (wsMatch && this.inspectorWsUrlPromise) { + const wsUrl = wsMatch[0] + logger.info(`Found inspector WebSocket URL in stderr: ${wsUrl}`) + this.inspectorWsUrl = wsUrl + this.inspectorWsUrlPromise.resolve(wsUrl) + this.inspectorWsUrlPromise = null + } + + // Forward non-inspector output to the client + // Skip the Bun Inspector banner + if (!text.includes('Bun Inspector') && !text.includes('Listening:') && + !text.includes('ws://') && !text.includes('debug.bun.sh')) { + this.sendEvent('output', { category: 'stderr', output: text }) + } + } + } catch (error) { + logger.debug('Stderr stream ended:', error) + } + } + + /** + * Connect to Bun's inspector WebSocket. + */ + private async connectToInspector(wsUrl: string): Promise { + logger.info(`Connecting to inspector at ${wsUrl}`) + + return new Promise((resolve, reject) => { + this.inspectorWs = new WebSocket(wsUrl) + + const timeout = setTimeout(() => { + reject(new Error('Inspector connection timeout')) + }, 5000) + + this.inspectorWs.onopen = async () => { + clearTimeout(timeout) + logger.info('Connected to inspector') + + try { + // Enable inspector domain first (required for Bun's WebKit Inspector Protocol) + await this.sendInspectorCommand('Inspector.enable', {}) + + // Enable console for output + await this.sendInspectorCommand('Console.enable', {}) + + // Enable the debugger domain + await this.sendInspectorCommand('Debugger.enable', {}) + + // Enable runtime domain for evaluation + await this.sendInspectorCommand('Runtime.enable', {}) + + // CRITICAL: Activate breakpoints (required for Bun) + await this.sendInspectorCommand('Debugger.setBreakpointsActive', { active: true }) + + // CRITICAL: Enable pause on debugger statements (required for Bun) + await this.sendInspectorCommand('Debugger.setPauseOnDebuggerStatements', { enabled: true }) + + // Set pause on exceptions + await this.sendInspectorCommand('Debugger.setPauseOnExceptions', { + state: 'uncaught' + }) + + // Set breakpoints BEFORE starting execution + await this.applyBreakpointsByUrl() + + this.running = true + + // CRITICAL: Call Inspector.initialized to start script execution + // Without this, Bun waits indefinitely with --inspect-wait + logger.info('Starting script execution with Inspector.initialized...') + await this.sendInspectorCommand('Inspector.initialized', {}) + + resolve() + } catch (error) { + reject(error) + } + } + + this.inspectorWs.onmessage = (event) => { + this.handleInspectorMessage(event.data as string) + } + + this.inspectorWs.onerror = (error) => { + logger.error('Inspector WebSocket error:', error) + } + + this.inspectorWs.onclose = () => { + logger.info('Inspector WebSocket closed') + this.inspectorWs = null + + // When inspector closes, the script has ended - send terminated event + if (!this.terminatedSent) { + this.terminatedSent = true + this.running = false + logger.info(`Inspector closed - sending terminated event with result: ${JSON.stringify(this.scriptResult)}`) + try { + this.sendEvent('terminated', this.scriptResult !== undefined ? { result: this.scriptResult } : {}) + logger.info('Terminated event sent successfully via inspector close') + } catch (error) { + logger.error('Failed to send terminated event on inspector close:', error) + } + } + } + }) + } + + /** + * Handle the 'threads' request. + */ + async handleThreads(request: DAPMessage): Promise { + this.sendResponse(request, true, { + threads: [{ id: 1, name: 'MainThread' }] + }) + } + + /** + * Handle the 'stackTrace' request. + */ + async handleStackTrace(request: DAPMessage): Promise { + logger.info(`handleStackTrace: callFrames.length=${this.callFrames.length}`) + const stackFrames: StackFrame[] = [] + + // Filter to only include frames from the user's script + // The call stack should start at module code, not include internal Bun/loader frames + for (let i = 0; i < this.callFrames.length; i++) { + const frame = this.callFrames[i] + const script = this.scripts.get(frame.location.scriptId) + + logger.info(` frame ${i}: functionName="${frame.functionName}", scriptId=${frame.location.scriptId}, lineNumber=${frame.location.lineNumber}, scriptUrl=${script?.url}`) + + // Only include frames from the main script + if (!script?.url || !this.scriptPath) { + logger.info(` skipping frame ${i}: no script URL or scriptPath`) + continue + } + + // Check if this frame is from the user's script + const isUserScript = script.url.endsWith(this.scriptPath.split('/').pop()!) + if (!isUserScript) { + logger.info(` skipping frame ${i}: not from user script (${script.url})`) + continue + } + + // Inspector line is 0-indexed (transpiled line) + // We need to convert it back to the original user line using the source map + const transpiledLine = frame.location.lineNumber + let frameLine = transpiledLine + if (this.sourceMapMappings.transpiledToOriginal.size > 0) { + const originalLine = this.sourceMapMappings.transpiledToOriginal.get(transpiledLine) + if (originalLine !== undefined) { + frameLine = originalLine + } + } + + stackFrames.push({ + id: i + 1, + name: frame.functionName || '', + source: { + path: script.url || this.scriptPath || '', + name: script.url?.split('/').pop() || 'script.ts' + }, + line: frameLine, + column: frame.location.columnNumber + 1 + }) + } + + logger.info(`handleStackTrace: returning ${stackFrames.length} frames`) + this.sendResponse(request, true, { + stackFrames, + totalFrames: stackFrames.length + }) + } + + /** + * Handle the 'scopes' request. + */ + async handleScopes(request: DAPMessage): Promise { + const args = request.arguments || {} + const frameId = (args.frameId as number) || 1 + const frameIndex = frameId - 1 + + logger.info(`handleScopes: frameId=${frameId}, frameIndex=${frameIndex}, callFrames.length=${this.callFrames.length}`) + + const frame = this.callFrames[frameIndex] + if (!frame) { + logger.warn(`handleScopes: No frame at index ${frameIndex}`) + this.sendResponse(request, true, { scopes: [] }) + return + } + + logger.info(`handleScopes: frame.scopeChain has ${frame.scopeChain.length} scopes`) + for (const s of frame.scopeChain) { + logger.info(` scope: type=${s.type}, name=${s.name}, hasObjectId=${!!s.object.objectId}`) + } + + const scopes: Array<{ name: string; variablesReference: number; expensive: boolean }> = [] + + for (const scope of frame.scopeChain) { + const ref = this.nextVarRef() + const objectId = scope.object.objectId + + if (objectId) { + this.scopesMap.set(ref, { type: scope.type, objectId, frameIndex }) + + // Format the name nicely + let name: string + if (scope.name) { + name = scope.name + } else { + name = scope.type.charAt(0).toUpperCase() + scope.type.slice(1) + } + + scopes.push({ + name, + variablesReference: ref, + expensive: scope.type === 'global' + }) + logger.info(` Added scope: name=${name}, ref=${ref}, type=${scope.type}`) + } else { + logger.warn(` Scope ${scope.type} has no objectId, skipping`) + } + } + + logger.info(`handleScopes: returning ${scopes.length} scopes`) + this.sendResponse(request, true, { scopes }) + } + + /** + * Handle the 'variables' request. + */ + async handleVariables(request: DAPMessage): Promise { + const args = request.arguments || {} + const variablesRef = (args.variablesReference as number) || 0 + + logger.info(`handleVariables: variablesRef=${variablesRef}`) + + const scopeInfo = this.scopesMap.get(variablesRef) + const objectId = this.objectsMap.get(variablesRef) || scopeInfo?.objectId + + logger.info(`handleVariables: scopeInfo=${JSON.stringify(scopeInfo)}, objectId=${objectId}`) + + if (!objectId) { + logger.warn(`handleVariables: No objectId found for ref ${variablesRef}`) + this.sendResponse(request, true, { variables: [] }) + return + } + + try { + // Determine scope type for filtering + const scopeType = scopeInfo?.type || 'unknown' + const isGlobalScope = scopeType === 'global' + + logger.info(`handleVariables: calling Runtime.getProperties for objectId=${objectId}, scopeType=${scopeType}`) + + const response = await this.sendInspectorCommand('Runtime.getProperties', { + objectId, + ownProperties: true, + generatePreview: true + }) + + // Log all property names for debugging + const allProps = (response.result?.properties as Array<{ name: string }>) || [] + logger.info(`handleVariables: all property names: ${allProps.map(p => p.name).join(', ')}`) + + logger.info(`handleVariables: got response with ${(response.result?.properties as unknown[])?.length || 0} properties for scope type: ${scopeType}`) + + const variables: Variable[] = [] + const properties = (response.result?.properties as Array<{ + name: string + value?: { + type: string + value?: unknown + description?: string + objectId?: string + subtype?: string + className?: string + preview?: { + type: string + subtype?: string + description?: string + overflow?: boolean + properties?: Array<{ name: string; type: string; value?: string; subtype?: string }> + } + } + configurable?: boolean + enumerable?: boolean + writable?: boolean + }>) || [] + + for (const prop of properties) { + if (!prop.value) continue + + // Skip internal properties (start with __) + if (prop.name.startsWith('__') && prop.name !== '__windmill_result__') continue + + // Skip native functions (built-in methods) + if (prop.value.type === 'function' && prop.value.description?.includes('[native code]')) continue + + // Skip common built-in object names + const builtInNames = new Set([ + 'NaN', 'Infinity', 'undefined', 'globalThis', 'global', 'self', 'window', + 'console', 'Bun', 'process', 'navigator', 'performance', 'crypto', 'Loader', + 'onmessage', 'onerror', 'toString', 'toLocaleString', 'valueOf', + 'hasOwnProperty', 'propertyIsEnumerable', 'isPrototypeOf', 'constructor', + // Built-in objects + 'Reflect', 'JSON', 'Math', 'Atomics', 'Intl', 'WebAssembly', 'Proxy', + 'Object', 'Array', 'Function', 'Boolean', 'Symbol', 'Number', 'BigInt', + 'String', 'RegExp', 'Date', 'Promise', 'Map', 'Set', 'WeakMap', 'WeakSet', + 'Error', 'TypeError', 'RangeError', 'SyntaxError', 'ReferenceError', + 'EvalError', 'URIError', 'AggregateError', 'ArrayBuffer', 'DataView', + 'Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array', 'Uint16Array', + 'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array', 'BigInt64Array', + 'BigUint64Array', 'SharedArrayBuffer' + ]) + if (builtInNames.has(prop.name)) continue + + // Skip properties that look like class/constructor names (PascalCase and are functions) + if (prop.value.type === 'function' && /^[A-Z][a-zA-Z0-9]*$/.test(prop.name)) continue + + let value: string + let varRef = 0 + let displayType = prop.value.type + const className = prop.value.className + + // Log for debugging variable parsing issues + logger.debug(`Variable ${prop.name}: type=${prop.value.type}, className=${className}, description=${prop.value.description}, value=${JSON.stringify(prop.value.value)}`) + + if (prop.value.type === 'object' && prop.value.objectId) { + // Check if this is a boxed primitive (String, Number, Boolean) + if (className === 'String' && prop.value.description) { + // Boxed string - description contains the string value + value = prop.value.description.startsWith('"') ? prop.value.description : `"${prop.value.description}"` + displayType = 'string' + } else if (className === 'Number' && prop.value.description) { + value = prop.value.description + displayType = 'number' + } else if (className === 'Boolean' && prop.value.description) { + value = prop.value.description + displayType = 'boolean' + } else { + // Regular object - create a reference for nested inspection + varRef = this.nextVarRef() + this.objectsMap.set(varRef, prop.value.objectId) + // Use preview data to show object contents instead of just "Object" + if (prop.value.preview && prop.value.preview.properties) { + const previewProps = prop.value.preview.properties + const isArray = prop.value.subtype === 'array' || prop.value.preview.subtype === 'array' + if (isArray) { + const items = previewProps.map((p) => this.formatPreviewValue(p)) + const suffix = prop.value.preview.overflow ? ', ...' : '' + value = `[${items.join(', ')}${suffix}]` + } else { + const items = previewProps.map((p) => `${p.name}: ${this.formatPreviewValue(p)}`) + const suffix = prop.value.preview.overflow ? ', ...' : '' + value = `{ ${items.join(', ')}${suffix} }` + } + } else { + value = prop.value.description || `[${prop.value.subtype || className || 'Object'}]` + } + } + } else if (prop.value.type === 'string') { + // Handle primitive strings - they have value property + value = prop.value.value !== undefined ? JSON.stringify(prop.value.value) : (prop.value.description || '""') + displayType = 'string' + } else if (prop.value.value !== undefined) { + value = JSON.stringify(prop.value.value) + } else { + value = prop.value.description || String(prop.value.type) + } + + variables.push({ + name: prop.name, + value, + type: displayType, + variablesReference: varRef + }) + } + + logger.info(`handleVariables: returning ${variables.length} variables (filtered from ${properties.length})`) + this.sendResponse(request, true, { variables }) + } catch (error) { + logger.error('Failed to get variables:', error) + this.sendResponse(request, true, { variables: [] }) + } + } + + /** + * Handle the 'evaluate' request. + */ + async handleEvaluate(request: DAPMessage): Promise { + const args = request.arguments || {} + let expression = (args.expression as string) || '' + const frameId = args.frameId as number | undefined + const token = args.token as string | undefined + + // Verify expression token if provided (optional - for audit logging) + // Expression tokens are signed by the backend to create audit logs + if (token) { + const verificationError = await verifyExpressionToken(token, expression) + if (verificationError) { + // Log the error but don't block evaluation + // Expression signing is for audit logging, not security enforcement + logger.warn(`Expression token verification failed: ${verificationError}`) + } + } + + // If expression starts with 'await ', strip it and we'll await the promise result + // This preserves the scope context unlike wrapping in an async IIFE + const shouldAwait = /^\s*await\s+/.test(expression) + if (shouldAwait) { + expression = expression.replace(/^\s*await\s+/, '') + } + + try { + let result: string + let varRef = 0 + + // Helper to format evaluation result + const formatResult = async (evalResult: { + type: string + subtype?: string + className?: string + value?: unknown + description?: string + objectId?: string + preview?: { properties?: Array<{ name: string; value: string }> } + } | undefined): Promise<{ result: string; varRef: number }> => { + let result = 'undefined' + let varRef = 0 + + if (!evalResult) return { result, varRef } + + // Check if result is a Promise - extract value from preview if already settled + // (Runtime.awaitPromise doesn't work reliably in Bun's inspector) + if (evalResult.subtype === 'promise' || evalResult.className === 'Promise') { + if (evalResult.preview?.properties) { + const props = evalResult.preview.properties as Array<{ + name: string + type?: string + value?: string + subtype?: string + valuePreview?: { properties?: Array<{ name: string; value?: string }> } + }> + const statusProp = props.find(p => p.name === 'status') + const resultProp = props.find(p => p.name === 'result') + + if (statusProp?.value === 'fulfilled' && resultProp) { + // Promise is already fulfilled - return the result directly + // For primitives, value is directly available + if (resultProp.value !== undefined) { + return { result: resultProp.value, varRef: 0 } + } + // For objects, format from valuePreview or show type + if (resultProp.type === 'object') { + if (resultProp.valuePreview?.properties) { + const objProps = resultProp.valuePreview.properties + .map(p => `${p.name}: ${p.value ?? '...'}`) + .join(', ') + return { result: `{${objProps}}`, varRef: 0 } + } + // Fallback: show the subtype or generic object + return { result: resultProp.subtype || '[Object]', varRef: 0 } + } + // For other types (function, symbol, etc) + return { result: resultProp.type || 'undefined', varRef: 0 } + } else if (statusProp?.value === 'rejected' && resultProp) { + // Promise was rejected - show the error + return { result: `Rejected: ${resultProp.value ?? resultProp.type}`, varRef: 0 } + } + // For pending promises, show that it's pending + if (statusProp?.value === 'pending') { + return { result: 'Promise { }', varRef: 0 } + } + } + } + + if (evalResult.type === 'undefined') { + result = 'undefined' + } else if (evalResult.value !== undefined) { + result = JSON.stringify(evalResult.value) + } else if (evalResult.objectId) { + varRef = this.nextVarRef() + this.objectsMap.set(varRef, evalResult.objectId) + // Use preview if available for better display + if (evalResult.preview?.properties) { + const props = evalResult.preview.properties.map(p => `${p.name}: ${p.value}`).join(', ') + result = `{${props}}` + } else { + result = evalResult.description || '[Object]' + } + } else { + result = evalResult.description || String(evalResult.type) + } + + return { result, varRef } + } + + if (frameId !== undefined && this.callFrames[frameId - 1]) { + const frame = this.callFrames[frameId - 1] + const response = await this.sendInspectorCommand('Debugger.evaluateOnCallFrame', { + callFrameId: frame.callFrameId, + expression, + returnByValue: false, + generatePreview: true + }) + + const evalResult = response.result?.result as { + type: string + subtype?: string + className?: string + value?: unknown + description?: string + objectId?: string + preview?: { properties?: Array<{ name: string; value: string }> } + } + + const formatted = await formatResult(evalResult) + result = formatted.result + varRef = formatted.varRef + } else { + const response = await this.sendInspectorCommand('Runtime.evaluate', { + expression, + returnByValue: false, + generatePreview: true + }) + + const evalResult = response.result?.result as { + type: string + subtype?: string + className?: string + value?: unknown + description?: string + objectId?: string + preview?: { properties?: Array<{ name: string; value: string }> } + } + + const formatted = await formatResult(evalResult) + result = formatted.result + varRef = formatted.varRef + } + + this.sendResponse(request, true, { result, variablesReference: varRef }) + } catch (error) { + this.sendResponse(request, true, { + result: `Error: ${error}`, + variablesReference: 0 + }) + } + } + + /** + * Handle the 'continue' request. + */ + async handleContinue(request: DAPMessage): Promise { + try { + this.isStepping = true + this.pausedAtBreakpoint = false // Reset for next pause + await this.sendInspectorCommand('Debugger.resume', {}) + this.sendResponse(request, true, { allThreadsContinued: true }) + } catch (error) { + this.isStepping = false + this.sendResponse(request, false, {}, String(error)) + } + } + + /** + * Handle the 'next' (step over) request. + */ + async handleNext(request: DAPMessage): Promise { + try { + this.isStepping = true + this.pausedAtBreakpoint = false // Reset for next pause + await this.sendInspectorCommand('Debugger.stepOver', {}) + this.sendResponse(request) + } catch (error) { + this.isStepping = false + this.sendResponse(request, false, {}, String(error)) + } + } + + /** + * Handle the 'stepIn' request. + */ + async handleStepIn(request: DAPMessage): Promise { + try { + this.isStepping = true + this.pausedAtBreakpoint = false // Reset for next pause + await this.sendInspectorCommand('Debugger.stepInto', {}) + this.sendResponse(request) + } catch (error) { + this.isStepping = false + this.sendResponse(request, false, {}, String(error)) + } + } + + /** + * Handle the 'stepOut' request. + */ + async handleStepOut(request: DAPMessage): Promise { + try { + this.isStepping = true + this.pausedAtBreakpoint = false // Reset for next pause + await this.sendInspectorCommand('Debugger.stepOut', {}) + this.sendResponse(request) + } catch (error) { + this.isStepping = false + this.sendResponse(request, false, {}, String(error)) + } + } + + /** + * Handle the 'pause' request. + */ + async handlePause(request: DAPMessage): Promise { + try { + await this.sendInspectorCommand('Debugger.pause', {}) + this.sendResponse(request) + } catch (error) { + this.sendResponse(request, false, {}, String(error)) + } + } + + /** + * Handle the 'disconnect' request. + */ + async handleDisconnect(request: DAPMessage): Promise { + this.running = false + await this.cleanup() + this.sendResponse(request) + } + + /** + * Handle the 'terminate' request. + */ + async handleTerminate(request: DAPMessage): Promise { + this.running = false + // Set flag BEFORE cleanup to prevent inspector onclose from sending duplicate + const shouldSendTerminated = !this.terminatedSent + this.terminatedSent = true + await this.cleanup() + this.sendResponse(request) + // Include the captured result if available + if (shouldSendTerminated) { + this.sendEvent('terminated', this.scriptResult !== undefined ? { result: this.scriptResult } : {}) + } + } + + /** + * Clean up resources. + */ + private async cleanup(): Promise { + // Close inspector connection + if (this.inspectorWs) { + this.inspectorWs.close() + this.inspectorWs = null + } + + // Kill process + if (this.process) { + this.process.kill() + this.process = null + } + + // Clean up temp file + if (this.tempFile) { + try { + await unlink(this.tempFile) + } catch { + // Ignore + } + this.tempFile = null + } + + if (this.tempDir) { + try { + await rmdir(this.tempDir) + } catch { + // Ignore + } + this.tempDir = null + } + } + + /** + * Handle an incoming DAP request. + */ + async handleRequest(request: DAPMessage): Promise { + const command = request.command || '' + logger.debug(`Handling command: ${command}`) + + const handlers: Record Promise> = { + initialize: (req) => this.handleInitialize(req), + setBreakpoints: (req) => this.handleSetBreakpoints(req), + configurationDone: (req) => this.handleConfigurationDone(req), + launch: (req) => this.handleLaunch(req), + threads: (req) => this.handleThreads(req), + stackTrace: (req) => this.handleStackTrace(req), + scopes: (req) => this.handleScopes(req), + variables: (req) => this.handleVariables(req), + evaluate: (req) => this.handleEvaluate(req), + continue: (req) => this.handleContinue(req), + next: (req) => this.handleNext(req), + stepIn: (req) => this.handleStepIn(req), + stepOut: (req) => this.handleStepOut(req), + pause: (req) => this.handlePause(req), + disconnect: (req) => this.handleDisconnect(req), + terminate: (req) => this.handleTerminate(req) + } + + const handler = handlers[command] + if (handler) { + await handler(request) + } else { + logger.warn(`Unhandled command: ${command}`) + this.sendResponse(request, false, {}, `Unsupported command: ${command}`) + } + } +} + +// Store active sessions by WebSocket +const sessions = new Map() + +// Parse command line arguments +const args = process.argv.slice(2) +let host = 'localhost' +let port = 5680 // Different port from Python server +let windmillPath: string | undefined + +for (let i = 0; i < args.length; i++) { + if (args[i] === '--host' && args[i + 1]) { + host = args[i + 1] + i++ + } else if (args[i] === '--port' && args[i + 1]) { + port = parseInt(args[i + 1], 10) + i++ + } else if (args[i] === '--windmill' && args[i + 1]) { + windmillPath = args[i + 1] + i++ + } +} + +// Only start the standalone server when run directly (not when imported) +if (import.meta.main) { + if (windmillPath) { + logger.info(`Windmill binary path: ${windmillPath}`) + } + + // Start the server + logger.info(`Starting DAP WebSocket server on ws://${host}:${port}`) + + const server = Bun.serve({ + hostname: host, + port, + fetch(req, server) { + // Upgrade to WebSocket + if (server.upgrade(req)) { + return undefined as unknown as Response + } + return new Response('DAP WebSocket Server for Bun/TypeScript', { status: 200 }) + }, + websocket: { + open(ws) { + logger.info('New client connected') + // Create a wrapper that implements the WebSocket interface expected by DebugSession + const wsWrapper: WebSocket = { + send: (data: string) => ws.send(data), + close: () => ws.close(), + readyState: WebSocket.OPEN, + CONNECTING: WebSocket.CONNECTING, + OPEN: WebSocket.OPEN, + CLOSING: WebSocket.CLOSING, + CLOSED: WebSocket.CLOSED + } as unknown as WebSocket + + const session = new DebugSession(wsWrapper, { windmillPath }) + sessions.set(ws, session) + }, + async message(ws, message) { + const session = sessions.get(ws) + if (!session) return + + try { + const data = JSON.parse(message as string) as DAPMessage + logger.debug('Received:', JSON.stringify(data)) + + if (data.type === 'request') { + await session.handleRequest(data) + } + } catch (error) { + logger.error('Error handling message:', error) + } + }, + close(ws) { + logger.info('Client disconnected') + sessions.delete(ws) + } + } + }) + + logger.info(`Server started on ${server.url}`) +} diff --git a/debugger/out/dap_websocket_server_bun.js b/debugger/out/dap_websocket_server_bun.js new file mode 100755 index 0000000000..1116abf9f7 --- /dev/null +++ b/debugger/out/dap_websocket_server_bun.js @@ -0,0 +1,1529 @@ +#!/usr/bin/env bun +// @bun + +// dap_websocket_server_bun.ts +var {spawn } = globalThis.Bun; +import { mkdtemp, writeFile, unlink, rmdir, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +var LOG_LEVEL = process.env.LOG_LEVEL || "DEBUG"; +var logger = { + debug: (...args) => { + if (LOG_LEVEL === "DEBUG") + console.log("[DEBUG]", new Date().toISOString(), ...args); + }, + info: (...args) => console.log("[INFO]", new Date().toISOString(), ...args), + warn: (...args) => console.warn("[WARN]", new Date().toISOString(), ...args), + error: (...args) => console.error("[ERROR]", new Date().toISOString(), ...args) +}; +var WINDMILL_BASE_URL = process.env.WINDMILL_BASE_URL || process.env.BASE_INTERNAL_URL; +var REQUIRE_SIGNED_REQUESTS = process.env.REQUIRE_SIGNED_DEBUG_REQUESTS !== "false"; +var cachedPublicKey = null; +var publicKeyFetchPromise = null; +async function getPublicKey() { + if (cachedPublicKey) { + return cachedPublicKey; + } + if (publicKeyFetchPromise) { + return publicKeyFetchPromise; + } + if (!WINDMILL_BASE_URL) { + logger.warn("WINDMILL_BASE_URL not set - cannot fetch public key"); + return null; + } + publicKeyFetchPromise = (async () => { + try { + const jwksUrl = `${WINDMILL_BASE_URL.replace(/\/$/, "")}/api/debug/jwks`; + logger.info(`Fetching JWKS from ${jwksUrl}`); + const response = await fetch(jwksUrl); + if (!response.ok) { + throw new Error(`Failed to fetch JWKS: ${response.status} ${response.statusText}`); + } + const jwks = await response.json(); + if (!jwks.keys || jwks.keys.length === 0) { + throw new Error("No keys in JWKS"); + } + const jwk = jwks.keys[0]; + if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519") { + throw new Error(`Unsupported key type: ${jwk.kty}/${jwk.crv}`); + } + const publicKeyBytes = Uint8Array.from(atob(jwk.x.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)); + const key = await crypto.subtle.importKey("raw", publicKeyBytes, { name: "Ed25519" }, true, ["verify"]); + cachedPublicKey = key; + logger.info("Successfully loaded Ed25519 public key from JWKS"); + return key; + } catch (error) { + logger.error(`Failed to fetch/parse JWKS: ${error}`); + return null; + } finally { + publicKeyFetchPromise = null; + } + })(); + return publicKeyFetchPromise; +} +async function computeCodeHash(code) { + const encoder = new TextEncoder; + const data = encoder.encode(code); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = new Uint8Array(hashBuffer); + return Array.from(hashArray.slice(0, 16)).map((b) => b.toString(16).padStart(2, "0")).join(""); +} +function base64urlDecode(str) { + const padding = "=".repeat((4 - str.length % 4) % 4); + const base64 = str.replace(/-/g, "+").replace(/_/g, "/") + padding; + const binary = atob(base64); + return Uint8Array.from(binary, (c) => c.charCodeAt(0)); +} +async function verifyDebugToken(token, code) { + const publicKey = await getPublicKey(); + if (!publicKey) { + if (REQUIRE_SIGNED_REQUESTS) { + return "Public key not available but signed requests are required. Set WINDMILL_BASE_URL."; + } + logger.warn("Public key not available - signature verification disabled"); + return null; + } + const parts = token.split("."); + if (parts.length !== 3) { + return "Invalid JWT format"; + } + const [headerB64, claimsB64, signatureB64] = parts; + try { + const message = new TextEncoder().encode(`${headerB64}.${claimsB64}`); + const signature = base64urlDecode(signatureB64); + const isValid = await crypto.subtle.verify({ name: "Ed25519" }, publicKey, signature, message); + if (!isValid) { + return "Invalid JWT signature"; + } + const claimsJson = new TextDecoder().decode(base64urlDecode(claimsB64)); + const claims = JSON.parse(claimsJson); + const now = Math.floor(Date.now() / 1000); + if (now > claims.exp) { + return `Token expired: ${now - claims.exp} seconds ago`; + } + const expectedHash = await computeCodeHash(code); + if (claims.code_hash !== expectedHash) { + return "Code hash mismatch - code was modified after signing"; + } + logger.info(`Verified debug token from ${claims.email} in workspace ${claims.workspace_id} (job: ${claims.job_id})`); + return null; + } catch (error) { + return `JWT verification error: ${error}`; + } +} +function removePinnedImports(code) { + const IMPORTS_VERSION = /^((?:@[^/@]+\/[^/@]+)|(?:[^/@]+))(?:@[^/]+)?(.*)$/; + const importRegex = /(?:import|export).*?from\s+['"]([^'"]+)['"]/g; + let result = code; + let match; + const imports = []; + while ((match = importRegex.exec(code)) !== null) { + imports.push(match[1]); + } + imports.sort((a, b) => b.length - a.length); + for (const importPath of imports) { + const versionMatch = IMPORTS_VERSION.exec(importPath); + if (versionMatch) { + const packageName = versionMatch[1]; + const pathSuffix = versionMatch[2] || ""; + const newImportPath = packageName + pathSuffix; + if (newImportPath !== importPath) { + result = result.split(`"${importPath}"`).join(`"${newImportPath}"`); + result = result.split(`'${importPath}'`).join(`'${newImportPath}'`); + logger.debug(`Removed version from import: "${importPath}" -> "${newImportPath}"`); + } + } + } + return result; +} +function decodeVLQ(encoded) { + const VLQ_BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const values = []; + let shift = 0; + let value = 0; + for (const char of encoded) { + const digit = VLQ_BASE64.indexOf(char); + if (digit === -1) + continue; + const hasContinuation = digit & 32; + value += (digit & 31) << shift; + if (hasContinuation) { + shift += 5; + } else { + const isNegative = value & 1; + value = value >> 1; + values.push(isNegative ? -value : value); + value = 0; + shift = 0; + } + } + return values; +} +function parseSourceMapLineMapping(sourceMapUrl) { + const mappings = { + originalToTranspiled: new Map, + transpiledToOriginal: new Map + }; + try { + const match = sourceMapUrl.match(/^data:application\/json;base64,(.+)$/); + if (!match) { + logger.warn("Source map is not a base64 data URL"); + return mappings; + } + const decoded = atob(match[1]); + const sourceMap = JSON.parse(decoded); + if (!sourceMap.mappings) { + return mappings; + } + const lines = sourceMap.mappings.split(";"); + let originalLine = 0; + for (let transpiledLine = 0;transpiledLine < lines.length; transpiledLine++) { + const segments = lines[transpiledLine].split(","); + for (const segment of segments) { + if (!segment) + continue; + const values = decodeVLQ(segment); + if (values.length >= 3) { + originalLine += values[2]; + if (!mappings.originalToTranspiled.has(originalLine)) { + mappings.originalToTranspiled.set(originalLine, transpiledLine); + logger.debug(`Source map: original line ${originalLine} -> transpiled line ${transpiledLine}`); + } + const existing = mappings.transpiledToOriginal.get(transpiledLine); + if (existing === undefined || originalLine > existing) { + mappings.transpiledToOriginal.set(transpiledLine, originalLine); + logger.debug(`Source map: transpiled line ${transpiledLine} -> original line ${originalLine}`); + } + } + } + } + } catch (error) { + logger.error("Failed to parse source map:", error); + } + return mappings; +} + +class DebugSession { + ws; + seq = 1; + initialized = false; + configured = false; + running = false; + terminatedSent = false; + process = null; + inspectorWs = null; + inspectorSeq = 1; + pendingInspectorRequests = new Map; + scriptPath = null; + tempDir = null; + tempFile = null; + breakpoints = new Map; + breakpointIds = new Map; + scripts = new Map; + mainScriptId = null; + sourceMapMappings = { + originalToTranspiled: new Map, + transpiledToOriginal: new Map + }; + callFrames = []; + variablesRefCounter = 1; + scopesMap = new Map; + objectsMap = new Map; + callMain = false; + mainArgs = {}; + isStepping = false; + pendingConsoleOutput = []; + scriptResult = undefined; + pausedAtBreakpoint = false; + envVars = {}; + nsjailConfig; + bunPath = "bun"; + windmillPath; + nodeModulesPath; + constructor(ws, options) { + this.ws = ws; + this.nsjailConfig = options?.nsjailConfig; + this.bunPath = options?.bunPath || "bun"; + this.windmillPath = options?.windmillPath; + } + nextSeq() { + return this.seq++; + } + nextInspectorSeq() { + return this.inspectorSeq++; + } + nextVarRef() { + return this.variablesRefCounter++; + } + sendMessage(msg) { + const data = JSON.stringify(msg); + logger.debug("Sending DAP:", data); + this.ws.send(data); + } + sendResponse(request, success = true, body = {}, message = "") { + this.sendMessage({ + seq: this.nextSeq(), + type: "response", + command: request.command || "", + request_seq: request.seq, + success, + message, + body + }); + } + sendEvent(event, body = {}) { + this.sendMessage({ + seq: this.nextSeq(), + type: "event", + event, + body + }); + } + async sendInspectorCommand(method, params = {}) { + if (!this.inspectorWs || this.inspectorWs.readyState !== WebSocket.OPEN) { + throw new Error("Inspector not connected"); + } + const id = this.nextInspectorSeq(); + const message = { id, method, params }; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pendingInspectorRequests.delete(id); + reject(new Error(`Inspector command timeout: ${method}`)); + }, 1e4); + this.pendingInspectorRequests.set(id, { + resolve: (value) => { + clearTimeout(timeout); + resolve(value); + }, + reject: (error) => { + clearTimeout(timeout); + reject(error); + } + }); + logger.debug("Sending to inspector:", JSON.stringify(message)); + this.inspectorWs.send(JSON.stringify(message)); + }); + } + handleInspectorMessage(data) { + try { + const message = JSON.parse(data); + logger.debug("Inspector message:", data); + if (message.id !== undefined) { + const pending = this.pendingInspectorRequests.get(message.id); + if (pending) { + this.pendingInspectorRequests.delete(message.id); + if (message.error) { + pending.reject(new Error(message.error.message)); + } else { + pending.resolve(message); + } + } + return; + } + if (message.method) { + this.handleInspectorEvent(message); + } + } catch (error) { + logger.error("Failed to parse inspector message:", error); + } + } + handleInspectorEvent(message) { + const params = message.params || {}; + switch (message.method) { + case "Debugger.scriptParsed": + this.handleScriptParsed(params); + break; + case "Debugger.paused": + this.handlePaused(params); + break; + case "Debugger.resumed": + this.sendEvent("continued", { threadId: 1 }); + break; + case "Runtime.consoleAPICalled": + this.handleConsoleOutput(params); + break; + case "Console.messageAdded": + this.handleConsoleMessageAdded(params); + break; + case "Runtime.exceptionThrown": + this.handleException(params); + break; + case "Debugger.breakpointResolved": + logger.info(`Breakpoint resolved: ${JSON.stringify(params)}`); + break; + default: + logger.debug("Unhandled inspector event:", message.method); + } + } + handleScriptParsed(script) { + this.scripts.set(script.scriptId, script); + if (script.url && this.scriptPath && script.url.endsWith(this.scriptPath.split("/").pop())) { + this.mainScriptId = script.scriptId; + logger.info(`Main script parsed: ${script.url} (ID: ${script.scriptId})`); + if (script.sourceMapURL) { + logger.info("Parsing source map for line number mapping..."); + this.sourceMapMappings = parseSourceMapLineMapping(script.sourceMapURL); + logger.info(`Source map parsed: ${this.sourceMapMappings.originalToTranspiled.size} original->transpiled, ${this.sourceMapMappings.transpiledToOriginal.size} transpiled->original`); + } + } + } + async applyBreakpointsByUrl() { + logger.info(`applyBreakpointsByUrl called. scriptPath: ${this.scriptPath}, breakpoints: ${JSON.stringify([...this.breakpoints.entries()])}`); + if (!this.scriptPath) { + logger.warn("No scriptPath set, skipping breakpoints"); + return; + } + for (const [filePath, ids] of this.breakpointIds) { + for (const id of ids) { + try { + await this.sendInspectorCommand("Debugger.removeBreakpoint", { breakpointId: id }); + } catch {} + } + } + this.breakpointIds.clear(); + for (const [filePath, lines] of this.breakpoints) { + logger.info(`Setting breakpoints for ${filePath}: lines ${lines}`); + const ids = []; + for (const line of lines) { + try { + const urlRegex = this.scriptPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const originalLine0Indexed = line; + let transpiledLine = originalLine0Indexed; + if (this.sourceMapMappings.originalToTranspiled.size > 0) { + const mappedLine = this.sourceMapMappings.originalToTranspiled.get(originalLine0Indexed); + if (mappedLine !== undefined) { + transpiledLine = mappedLine; + logger.info(`Source map: original line ${originalLine0Indexed} -> transpiled line ${transpiledLine}`); + } else { + logger.warn(`Line ${originalLine0Indexed} not in source map, using as-is`); + } + } + logger.info(`Setting breakpoint: user line ${line} -> original line ${originalLine0Indexed} (0-idx) -> transpiled line ${transpiledLine}`); + const response = await this.sendInspectorCommand("Debugger.setBreakpointByUrl", { + lineNumber: transpiledLine, + urlRegex, + columnNumber: 0 + }); + logger.info(`Breakpoint response: ${JSON.stringify(response)}`); + if (response.result?.breakpointId) { + ids.push(response.result.breakpointId); + logger.info(`Breakpoint set at transpiled line ${transpiledLine} (user line ${line}, ID: ${response.result.breakpointId})`); + } else { + logger.warn(`No breakpointId in response for line ${line}`); + } + } catch (error) { + logger.error(`Failed to set breakpoint at line ${line}:`, error); + } + } + this.breakpointIds.set(filePath, ids); + } + logger.info(`Finished setting breakpoints. Total IDs: ${[...this.breakpointIds.values()].flat().length}`); + } + initialPauseDone = false; + async handlePaused(params) { + const callFrames = params.callFrames; + const reason = params.reason; + logger.info(`handlePaused called: reason=${reason}, lineNumber=${callFrames?.[0]?.location?.lineNumber}, scriptId=${callFrames?.[0]?.location?.scriptId}`); + if (callFrames) { + this.callFrames = callFrames; + } + const isInitialPause = !this.initialPauseDone && reason === "DebuggerStatement" && callFrames && callFrames.length > 0 && callFrames[0].location.lineNumber === 0; + logger.info(`isInitialPause=${isInitialPause}, initialPauseDone=${this.initialPauseDone}`); + if (isInitialPause) { + this.initialPauseDone = true; + logger.info("Initial pause from injected debugger statement"); + logger.info("Re-applying breakpoints after script is parsed..."); + await this.applyBreakpointsByUrl(); + try { + await this.sendInspectorCommand("Debugger.resume", {}); + logger.info("Auto-resumed after initial pause"); + } catch (error) { + logger.error("Failed to auto-resume:", error); + } + return; + } + this.initialPauseDone = true; + let dapReason; + switch (reason) { + case "Breakpoint": + case "breakpoint": + dapReason = "breakpoint"; + break; + case "DebuggerStatement": + dapReason = "breakpoint"; + break; + case "step": + dapReason = "step"; + break; + case "exception": + dapReason = "exception"; + break; + case "debugCommand": + dapReason = "pause"; + break; + default: + dapReason = "step"; + } + let line; + if (callFrames && callFrames.length > 0) { + const transpiledLine = callFrames[0].location.lineNumber; + if (this.sourceMapMappings.transpiledToOriginal.size > 0) { + const originalLine = this.sourceMapMappings.transpiledToOriginal.get(transpiledLine); + if (originalLine !== undefined) { + line = originalLine; + logger.info(`Stopped: transpiled line ${transpiledLine} -> original line ${line}`); + } else { + line = transpiledLine; + logger.warn(`Transpiled line ${transpiledLine} not in reverse source map, using as-is`); + } + } else { + line = transpiledLine; + } + } + this.sendEvent("stopped", { + reason: dapReason, + threadId: 1, + allThreadsStopped: true, + line + }); + this.isStepping = false; + for (const outputEvent of this.pendingConsoleOutput) { + this.sendEvent("output", outputEvent); + } + this.pendingConsoleOutput = []; + } + handleConsoleOutput(params) { + const type = params.type; + const args = params.args; + if (!args) + return; + const output = args.map((arg) => { + if (arg.value !== undefined) + return String(arg.value); + if (arg.description) + return arg.description; + return ""; + }).join(" "); + this.sendEvent("output", { + category: type === "error" ? "stderr" : "stdout", + output: output + ` +` + }); + } + formatConsoleParameter(p) { + if (p.value !== undefined) { + if (typeof p.value === "string") + return p.value; + return String(p.value); + } + if (p.subtype === "null") + return "null"; + if (p.type === "undefined") + return "undefined"; + if (p.type === "object" && p.preview && p.preview.properties) { + const props = p.preview.properties; + const isArray = p.subtype === "array" || p.preview.subtype === "array"; + if (isArray) { + const items = props.map((prop) => this.formatPreviewValue(prop)); + const suffix = p.preview.overflow ? ", ..." : ""; + return `[${items.join(", ")}${suffix}]`; + } else { + const items = props.map((prop) => `${prop.name}: ${this.formatPreviewValue(prop)}`); + const suffix = p.preview.overflow ? ", ..." : ""; + return `{ ${items.join(", ")}${suffix} }`; + } + } + if (p.type === "function") { + return p.description || "[Function]"; + } + if (p.description) + return p.description; + return ""; + } + formatPreviewValue(prop) { + if (prop.subtype === "null") + return "null"; + if (prop.type === "undefined") + return "undefined"; + if (prop.type === "string") + return `"${prop.value ?? ""}"`; + if (prop.type === "number" || prop.type === "boolean") + return prop.value ?? ""; + if (prop.type === "function") + return "[Function]"; + if (prop.type === "object") { + if (prop.subtype === "array") + return prop.value || "[]"; + return prop.value || "{...}"; + } + return prop.value ?? ""; + } + handleConsoleMessageAdded(params) { + const message = params.message; + if (!message) + return; + let output; + if (message.parameters && message.parameters.length > 0) { + output = message.parameters.map((p) => this.formatConsoleParameter(p)).join(" "); + } else { + output = message.text || ""; + } + if (output.startsWith("__WINDMILL_RESULT__:")) { + try { + const resultJson = output.substring("__WINDMILL_RESULT__:".length); + this.scriptResult = JSON.parse(resultJson); + logger.info(`Captured script result: ${resultJson}`); + this.isStepping = false; + for (const pendingOutput of this.pendingConsoleOutput) { + this.sendEvent("output", pendingOutput); + } + this.pendingConsoleOutput = []; + if (!this.terminatedSent) { + this.terminatedSent = true; + this.running = false; + logger.info("Sending terminated event after result capture"); + this.sendEvent("terminated", { result: this.scriptResult }); + } + } catch (error) { + logger.error("Failed to parse script result:", error); + } + return; + } + const category = message.level === "error" || message.level === "warning" ? "stderr" : "stdout"; + const outputEvent = { + category, + output: output + ` +` + }; + if (message.line !== undefined && message.url) { + outputEvent.source = { + path: message.url, + name: message.url.split("/").pop() || "script.ts" + }; + outputEvent.line = message.line; + if (message.column !== undefined) { + outputEvent.column = message.column + 1; + } + } + if (this.isStepping) { + this.pendingConsoleOutput.push(outputEvent); + } else { + this.sendEvent("output", outputEvent); + } + } + handleException(params) { + const exceptionDetails = params.exceptionDetails; + if (exceptionDetails) { + const message = exceptionDetails.exception?.description || exceptionDetails.text || "Unknown exception"; + this.sendEvent("output", { + category: "stderr", + output: `Exception: ${message} +` + }); + } + } + async handleInitialize(request) { + const 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 + }; + this.sendResponse(request, true, capabilities); + this.initialized = true; + this.sendEvent("initialized"); + } + async handleSetBreakpoints(request) { + const args = request.arguments || {}; + const source = args.source; + const sourcePath = source?.path || ""; + const breakpointsData = args.breakpoints || []; + const verifiedBreakpoints = []; + const lineNumbers = []; + for (const bp of breakpointsData) { + const line = bp.line; + lineNumbers.push(line); + verifiedBreakpoints.push({ + id: verifiedBreakpoints.length + 1, + verified: true, + line, + source: { path: sourcePath } + }); + } + this.breakpoints.set(sourcePath, lineNumbers); + logger.info(`Stored breakpoints at lines ${lineNumbers} for ${sourcePath}`); + if (this.inspectorWs) { + await this.applyBreakpointsByUrl(); + } + this.sendResponse(request, true, { breakpoints: verifiedBreakpoints }); + } + async handleConfigurationDone(request) { + this.configured = true; + this.sendResponse(request); + } + async handleLaunch(request) { + const args = request.arguments || {}; + let code = args.code; + this.scriptPath = args.program; + const cwd = args.cwd || process.cwd(); + this.callMain = args.callMain || false; + this.mainArgs = args.args || {}; + this.envVars = args.env || {}; + if (code && REQUIRE_SIGNED_REQUESTS) { + const token = args.token; + if (!token) { + logger.error("No debug token provided but signed requests are required"); + this.sendResponse(request, false, {}, "Debug token required. Ensure the debug session was signed by the backend."); + return; + } + const verificationError = await verifyDebugToken(token, code); + if (verificationError) { + logger.error(`Token verification failed: ${verificationError}`); + this.sendResponse(request, false, {}, `Token verification failed: ${verificationError}`); + return; + } + } + if (process.env.BASE_INTERNAL_URL) { + this.envVars.WM_BASE_URL = process.env.BASE_INTERNAL_URL; + } + if (Object.keys(this.envVars).length > 0) { + logger.info(`Launch with env vars: ${Object.keys(this.envVars).join(", ")}`); + } + if (!this.scriptPath && !code) { + this.sendResponse(request, false, {}, "No program or code specified"); + return; + } + if (code) { + this.nodeModulesPath = await this.prepareDependencies(code) || undefined; + code = removePinnedImports(code); + } + this.initialPauseDone = false; + code = `debugger; // Auto-injected by Windmill debugger +${code || ""}`; + if (this.callMain && code) { + const argsValues = Object.values(this.mainArgs).map((v) => JSON.stringify(v)).join(", "); + code = code + ` + +// Auto-generated call to main entrypoint +` + `globalThis.__windmill_result__ = await main(${argsValues}); +` + `console.log("__WINDMILL_RESULT__:" + JSON.stringify(globalThis.__windmill_result__)); +` + `// Small delay to ensure console output is delivered via inspector before process exits +` + `await new Promise(r => setTimeout(r, 50)); +`; + logger.info(`Added main() call with args: ${argsValues}`); + } + if (code && !this.scriptPath) { + try { + this.tempDir = await mkdtemp(join(tmpdir(), "windmill_debug_")); + this.tempFile = join(this.tempDir, "script.ts"); + await writeFile(this.tempFile, code); + this.scriptPath = this.tempFile; + logger.info(`Wrote code to ${this.tempFile}`); + const lines = code.split(` +`); + for (let i = 24;i < Math.min(30, lines.length); i++) { + logger.info(` Line ${i} (0-idx) / ${i + 1} (1-idx): ${lines[i]?.substring(0, 80)}`); + } + if (this.nodeModulesPath) { + const targetPath = join(this.tempDir, "node_modules"); + try { + await symlink(this.nodeModulesPath, targetPath); + logger.info(`Symlinked ${this.nodeModulesPath} -> ${targetPath}`); + } catch (symlinkError) { + logger.warn(`Failed to symlink node_modules: ${symlinkError}`); + } + } + } catch (error) { + this.sendResponse(request, false, {}, `Failed to create temp file: ${error}`); + return; + } + } + this.sendResponse(request); + try { + await this.startBunProcess(cwd); + } catch (error) { + this.sendEvent("output", { category: "stderr", output: `Failed to start Bun: ${error} +` }); + this.sendEvent("terminated", { error: String(error) }); + } + } + inspectorWsUrl = null; + inspectorWsUrlPromise = null; + async prepareDependencies(code, language = "bun") { + if (!this.windmillPath) { + logger.info("No windmill binary path configured, skipping dependency preparation"); + return null; + } + logger.info(`Preparing dependencies using ${this.windmillPath}`); + try { + const input = JSON.stringify({ code, language }) + ` +`; + logger.info(`prepare-deps input length: ${input.length}`); + const proc = spawn({ + cmd: [this.windmillPath, "prepare-deps"], + stdin: new Blob([input]), + stdout: "pipe", + stderr: "pipe" + }); + const output = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + logger.info(`prepare-deps output: ${output.substring(0, 200)}`); + logger.info(`prepare-deps stderr: ${stderr.substring(0, 200)}`); + if (stderr) { + const filteredStderr = stderr.split(` +`).filter((line) => !line.includes("Running in standalone mode")).join(` +`).trim(); + if (filteredStderr) { + logger.debug(`prepare-deps stderr: ${filteredStderr}`); + } + } + const response = JSON.parse(output.trim().split(` +`).pop() || "{}"); + if (!response.success) { + logger.error(`prepare-deps failed: ${response.error}`); + this.sendEvent("output", { + category: "console", + output: `Warning: Failed to prepare dependencies: ${response.error} +` + }); + return null; + } + if (response.node_modules_path) { + logger.info(`Dependencies installed at: ${response.node_modules_path}`); + this.sendEvent("output", { + category: "console", + output: `Dependencies installed at: ${response.node_modules_path} +` + }); + return response.node_modules_path; + } + logger.info("No external dependencies to install"); + return null; + } catch (error) { + logger.error(`Failed to prepare dependencies: ${error}`); + this.sendEvent("output", { + category: "console", + output: `Warning: Failed to prepare dependencies: ${error} +` + }); + return null; + } + } + async startBunProcess(cwd) { + if (!this.scriptPath) { + throw new Error("No script path"); + } + const inspectPort = 9229 + Math.floor(Math.random() * 1000); + const inspectUrl = `127.0.0.1:${inspectPort}`; + let cmd = [this.bunPath, `--inspect-wait=${inspectUrl}`, this.scriptPath]; + if (this.nsjailConfig?.enabled) { + const nsjailCmd = [this.nsjailConfig.binaryPath]; + if (this.nsjailConfig.configPath) { + nsjailCmd.push("--config", this.nsjailConfig.configPath); + } + if (this.nsjailConfig.extraArgs) { + nsjailCmd.push(...this.nsjailConfig.extraArgs); + } + nsjailCmd.push("--cwd", cwd); + nsjailCmd.push("--"); + nsjailCmd.push(...cmd); + cmd = nsjailCmd; + logger.info(`Starting Bun with nsjail: ${cmd.join(" ")}`); + } else { + logger.info(`Starting Bun with --inspect-wait=${inspectUrl}`); + } + const wsUrlPromise = new Promise((resolve, reject) => { + this.inspectorWsUrlPromise = { resolve, reject }; + setTimeout(() => { + if (this.inspectorWsUrlPromise) { + reject(new Error("Timeout waiting for inspector WebSocket URL")); + this.inspectorWsUrlPromise = null; + } + }, 1e4); + }); + const envVars = { + PATH: process.env.PATH || "/usr/bin:/bin", + HOME: process.env.HOME, + ...this.envVars + }; + if (this.nodeModulesPath) { + envVars.NODE_PATH = this.nodeModulesPath; + logger.info(`Setting NODE_PATH=${this.nodeModulesPath}`); + } + this.process = spawn({ + cmd, + cwd, + stdout: "pipe", + stderr: "pipe", + env: envVars + }); + this.readStderrForInspectorUrl(this.process.stderr); + const wsUrl = await wsUrlPromise; + logger.info(`Got inspector WebSocket URL: ${wsUrl}`); + await this.connectToInspector(wsUrl); + this.process.exited.then(async (exitCode) => { + logger.info(`Bun process exited with code: ${exitCode}`); + this.running = false; + if (!this.terminatedSent) { + this.terminatedSent = true; + logger.info(`Sending terminated event with result: ${JSON.stringify(this.scriptResult)}`); + try { + this.sendEvent("terminated", this.scriptResult !== undefined ? { result: this.scriptResult } : {}); + logger.info("Terminated event sent successfully"); + } catch (error) { + logger.error("Failed to send terminated event:", error); + } + } + await this.cleanup(); + }).catch((error) => { + logger.error("Error in process.exited handler:", error); + }); + } + async readStream(stream, category) { + if (!stream) + return; + const reader = stream.getReader(); + const decoder = new TextDecoder; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) + break; + const text = decoder.decode(value); + this.sendEvent("output", { category, output: text }); + } + } catch (error) { + logger.debug(`Stream ${category} ended:`, error); + } + } + async readStderrForInspectorUrl(stream) { + if (!stream) + return; + const reader = stream.getReader(); + const decoder = new TextDecoder; + let buffer = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) + break; + const text = decoder.decode(value); + buffer += text; + const wsMatch = buffer.match(/ws:\/\/[\d.]+:\d+\/[a-z0-9]+/i); + if (wsMatch && this.inspectorWsUrlPromise) { + const wsUrl = wsMatch[0]; + logger.info(`Found inspector WebSocket URL in stderr: ${wsUrl}`); + this.inspectorWsUrl = wsUrl; + this.inspectorWsUrlPromise.resolve(wsUrl); + this.inspectorWsUrlPromise = null; + } + if (!text.includes("Bun Inspector") && !text.includes("Listening:") && !text.includes("ws://") && !text.includes("debug.bun.sh")) { + this.sendEvent("output", { category: "stderr", output: text }); + } + } + } catch (error) { + logger.debug("Stderr stream ended:", error); + } + } + async connectToInspector(wsUrl) { + logger.info(`Connecting to inspector at ${wsUrl}`); + return new Promise((resolve, reject) => { + this.inspectorWs = new WebSocket(wsUrl); + const timeout = setTimeout(() => { + reject(new Error("Inspector connection timeout")); + }, 5000); + this.inspectorWs.onopen = async () => { + clearTimeout(timeout); + logger.info("Connected to inspector"); + try { + await this.sendInspectorCommand("Inspector.enable", {}); + await this.sendInspectorCommand("Console.enable", {}); + await this.sendInspectorCommand("Debugger.enable", {}); + await this.sendInspectorCommand("Runtime.enable", {}); + await this.sendInspectorCommand("Debugger.setBreakpointsActive", { active: true }); + await this.sendInspectorCommand("Debugger.setPauseOnDebuggerStatements", { enabled: true }); + await this.sendInspectorCommand("Debugger.setPauseOnExceptions", { + state: "uncaught" + }); + await this.applyBreakpointsByUrl(); + this.running = true; + logger.info("Starting script execution with Inspector.initialized..."); + await this.sendInspectorCommand("Inspector.initialized", {}); + resolve(); + } catch (error) { + reject(error); + } + }; + this.inspectorWs.onmessage = (event) => { + this.handleInspectorMessage(event.data); + }; + this.inspectorWs.onerror = (error) => { + logger.error("Inspector WebSocket error:", error); + }; + this.inspectorWs.onclose = () => { + logger.info("Inspector WebSocket closed"); + this.inspectorWs = null; + if (!this.terminatedSent) { + this.terminatedSent = true; + this.running = false; + logger.info(`Inspector closed - sending terminated event with result: ${JSON.stringify(this.scriptResult)}`); + try { + this.sendEvent("terminated", this.scriptResult !== undefined ? { result: this.scriptResult } : {}); + logger.info("Terminated event sent successfully via inspector close"); + } catch (error) { + logger.error("Failed to send terminated event on inspector close:", error); + } + } + }; + }); + } + async handleThreads(request) { + this.sendResponse(request, true, { + threads: [{ id: 1, name: "MainThread" }] + }); + } + async handleStackTrace(request) { + logger.info(`handleStackTrace: callFrames.length=${this.callFrames.length}`); + const stackFrames = []; + for (let i = 0;i < this.callFrames.length; i++) { + const frame = this.callFrames[i]; + const script = this.scripts.get(frame.location.scriptId); + logger.info(` frame ${i}: functionName="${frame.functionName}", scriptId=${frame.location.scriptId}, lineNumber=${frame.location.lineNumber}, scriptUrl=${script?.url}`); + if (!script?.url || !this.scriptPath) { + logger.info(` skipping frame ${i}: no script URL or scriptPath`); + continue; + } + const isUserScript = script.url.endsWith(this.scriptPath.split("/").pop()); + if (!isUserScript) { + logger.info(` skipping frame ${i}: not from user script (${script.url})`); + continue; + } + const transpiledLine = frame.location.lineNumber; + let frameLine = transpiledLine; + if (this.sourceMapMappings.transpiledToOriginal.size > 0) { + const originalLine = this.sourceMapMappings.transpiledToOriginal.get(transpiledLine); + if (originalLine !== undefined) { + frameLine = originalLine; + } + } + stackFrames.push({ + id: i + 1, + name: frame.functionName || "", + source: { + path: script.url || this.scriptPath || "", + name: script.url?.split("/").pop() || "script.ts" + }, + line: frameLine, + column: frame.location.columnNumber + 1 + }); + } + logger.info(`handleStackTrace: returning ${stackFrames.length} frames`); + this.sendResponse(request, true, { + stackFrames, + totalFrames: stackFrames.length + }); + } + async handleScopes(request) { + const args = request.arguments || {}; + const frameId = args.frameId || 1; + const frameIndex = frameId - 1; + logger.info(`handleScopes: frameId=${frameId}, frameIndex=${frameIndex}, callFrames.length=${this.callFrames.length}`); + const frame = this.callFrames[frameIndex]; + if (!frame) { + logger.warn(`handleScopes: No frame at index ${frameIndex}`); + this.sendResponse(request, true, { scopes: [] }); + return; + } + logger.info(`handleScopes: frame.scopeChain has ${frame.scopeChain.length} scopes`); + for (const s of frame.scopeChain) { + logger.info(` scope: type=${s.type}, name=${s.name}, hasObjectId=${!!s.object.objectId}`); + } + const scopes = []; + for (const scope of frame.scopeChain) { + const ref = this.nextVarRef(); + const objectId = scope.object.objectId; + if (objectId) { + this.scopesMap.set(ref, { type: scope.type, objectId, frameIndex }); + let name; + if (scope.name) { + name = scope.name; + } else { + name = scope.type.charAt(0).toUpperCase() + scope.type.slice(1); + } + scopes.push({ + name, + variablesReference: ref, + expensive: scope.type === "global" + }); + logger.info(` Added scope: name=${name}, ref=${ref}, type=${scope.type}`); + } else { + logger.warn(` Scope ${scope.type} has no objectId, skipping`); + } + } + logger.info(`handleScopes: returning ${scopes.length} scopes`); + this.sendResponse(request, true, { scopes }); + } + async handleVariables(request) { + const args = request.arguments || {}; + const variablesRef = args.variablesReference || 0; + logger.info(`handleVariables: variablesRef=${variablesRef}`); + const scopeInfo = this.scopesMap.get(variablesRef); + const objectId = this.objectsMap.get(variablesRef) || scopeInfo?.objectId; + logger.info(`handleVariables: scopeInfo=${JSON.stringify(scopeInfo)}, objectId=${objectId}`); + if (!objectId) { + logger.warn(`handleVariables: No objectId found for ref ${variablesRef}`); + this.sendResponse(request, true, { variables: [] }); + return; + } + try { + const scopeType = scopeInfo?.type || "unknown"; + const isGlobalScope = scopeType === "global"; + logger.info(`handleVariables: calling Runtime.getProperties for objectId=${objectId}, scopeType=${scopeType}`); + const response = await this.sendInspectorCommand("Runtime.getProperties", { + objectId, + ownProperties: true, + generatePreview: true + }); + const allProps = response.result?.properties || []; + logger.info(`handleVariables: all property names: ${allProps.map((p) => p.name).join(", ")}`); + logger.info(`handleVariables: got response with ${response.result?.properties?.length || 0} properties for scope type: ${scopeType}`); + const variables = []; + const properties = response.result?.properties || []; + for (const prop of properties) { + if (!prop.value) + continue; + if (prop.name.startsWith("__") && prop.name !== "__windmill_result__") + continue; + if (prop.value.type === "function" && prop.value.description?.includes("[native code]")) + continue; + const builtInNames = new Set([ + "NaN", + "Infinity", + "undefined", + "globalThis", + "global", + "self", + "window", + "console", + "Bun", + "process", + "navigator", + "performance", + "crypto", + "Loader", + "onmessage", + "onerror", + "toString", + "toLocaleString", + "valueOf", + "hasOwnProperty", + "propertyIsEnumerable", + "isPrototypeOf", + "constructor", + "Reflect", + "JSON", + "Math", + "Atomics", + "Intl", + "WebAssembly", + "Proxy", + "Object", + "Array", + "Function", + "Boolean", + "Symbol", + "Number", + "BigInt", + "String", + "RegExp", + "Date", + "Promise", + "Map", + "Set", + "WeakMap", + "WeakSet", + "Error", + "TypeError", + "RangeError", + "SyntaxError", + "ReferenceError", + "EvalError", + "URIError", + "AggregateError", + "ArrayBuffer", + "DataView", + "Int8Array", + "Uint8Array", + "Uint8ClampedArray", + "Int16Array", + "Uint16Array", + "Int32Array", + "Uint32Array", + "Float32Array", + "Float64Array", + "BigInt64Array", + "BigUint64Array", + "SharedArrayBuffer" + ]); + if (builtInNames.has(prop.name)) + continue; + if (prop.value.type === "function" && /^[A-Z][a-zA-Z0-9]*$/.test(prop.name)) + continue; + let value; + let varRef = 0; + let displayType = prop.value.type; + const className = prop.value.className; + logger.debug(`Variable ${prop.name}: type=${prop.value.type}, className=${className}, description=${prop.value.description}, value=${JSON.stringify(prop.value.value)}`); + if (prop.value.type === "object" && prop.value.objectId) { + if (className === "String" && prop.value.description) { + value = prop.value.description.startsWith('"') ? prop.value.description : `"${prop.value.description}"`; + displayType = "string"; + } else if (className === "Number" && prop.value.description) { + value = prop.value.description; + displayType = "number"; + } else if (className === "Boolean" && prop.value.description) { + value = prop.value.description; + displayType = "boolean"; + } else { + varRef = this.nextVarRef(); + this.objectsMap.set(varRef, prop.value.objectId); + if (prop.value.preview && prop.value.preview.properties) { + const previewProps = prop.value.preview.properties; + const isArray = prop.value.subtype === "array" || prop.value.preview.subtype === "array"; + if (isArray) { + const items = previewProps.map((p) => this.formatPreviewValue(p)); + const suffix = prop.value.preview.overflow ? ", ..." : ""; + value = `[${items.join(", ")}${suffix}]`; + } else { + const items = previewProps.map((p) => `${p.name}: ${this.formatPreviewValue(p)}`); + const suffix = prop.value.preview.overflow ? ", ..." : ""; + value = `{ ${items.join(", ")}${suffix} }`; + } + } else { + value = prop.value.description || `[${prop.value.subtype || className || "Object"}]`; + } + } + } else if (prop.value.type === "string") { + value = prop.value.value !== undefined ? JSON.stringify(prop.value.value) : prop.value.description || '""'; + displayType = "string"; + } else if (prop.value.value !== undefined) { + value = JSON.stringify(prop.value.value); + } else { + value = prop.value.description || String(prop.value.type); + } + variables.push({ + name: prop.name, + value, + type: displayType, + variablesReference: varRef + }); + } + logger.info(`handleVariables: returning ${variables.length} variables (filtered from ${properties.length})`); + this.sendResponse(request, true, { variables }); + } catch (error) { + logger.error("Failed to get variables:", error); + this.sendResponse(request, true, { variables: [] }); + } + } + async handleEvaluate(request) { + const args = request.arguments || {}; + let expression = args.expression || ""; + const frameId = args.frameId; + const shouldAwait = /^\s*await\s+/.test(expression); + if (shouldAwait) { + expression = expression.replace(/^\s*await\s+/, ""); + } + try { + let result; + let varRef = 0; + const formatResult = async (evalResult) => { + let result2 = "undefined"; + let varRef2 = 0; + if (!evalResult) + return { result: result2, varRef: varRef2 }; + if (evalResult.subtype === "promise" || evalResult.className === "Promise") { + if (evalResult.preview?.properties) { + const props = evalResult.preview.properties; + const statusProp = props.find((p) => p.name === "status"); + const resultProp = props.find((p) => p.name === "result"); + if (statusProp?.value === "fulfilled" && resultProp) { + if (resultProp.value !== undefined) { + return { result: resultProp.value, varRef: 0 }; + } + if (resultProp.type === "object") { + if (resultProp.valuePreview?.properties) { + const objProps = resultProp.valuePreview.properties.map((p) => `${p.name}: ${p.value ?? "..."}`).join(", "); + return { result: `{${objProps}}`, varRef: 0 }; + } + return { result: resultProp.subtype || "[Object]", varRef: 0 }; + } + return { result: resultProp.type || "undefined", varRef: 0 }; + } else if (statusProp?.value === "rejected" && resultProp) { + return { result: `Rejected: ${resultProp.value ?? resultProp.type}`, varRef: 0 }; + } + if (statusProp?.value === "pending") { + return { result: "Promise { }", varRef: 0 }; + } + } + } + if (evalResult.type === "undefined") { + result2 = "undefined"; + } else if (evalResult.value !== undefined) { + result2 = JSON.stringify(evalResult.value); + } else if (evalResult.objectId) { + varRef2 = this.nextVarRef(); + this.objectsMap.set(varRef2, evalResult.objectId); + if (evalResult.preview?.properties) { + const props = evalResult.preview.properties.map((p) => `${p.name}: ${p.value}`).join(", "); + result2 = `{${props}}`; + } else { + result2 = evalResult.description || "[Object]"; + } + } else { + result2 = evalResult.description || String(evalResult.type); + } + return { result: result2, varRef: varRef2 }; + }; + if (frameId !== undefined && this.callFrames[frameId - 1]) { + const frame = this.callFrames[frameId - 1]; + const response = await this.sendInspectorCommand("Debugger.evaluateOnCallFrame", { + callFrameId: frame.callFrameId, + expression, + returnByValue: false, + generatePreview: true + }); + const evalResult = response.result?.result; + const formatted = await formatResult(evalResult); + result = formatted.result; + varRef = formatted.varRef; + } else { + const response = await this.sendInspectorCommand("Runtime.evaluate", { + expression, + returnByValue: false, + generatePreview: true + }); + const evalResult = response.result?.result; + const formatted = await formatResult(evalResult); + result = formatted.result; + varRef = formatted.varRef; + } + this.sendResponse(request, true, { result, variablesReference: varRef }); + } catch (error) { + this.sendResponse(request, true, { + result: `Error: ${error}`, + variablesReference: 0 + }); + } + } + async handleContinue(request) { + try { + this.isStepping = true; + this.pausedAtBreakpoint = false; + await this.sendInspectorCommand("Debugger.resume", {}); + this.sendResponse(request, true, { allThreadsContinued: true }); + } catch (error) { + this.isStepping = false; + this.sendResponse(request, false, {}, String(error)); + } + } + async handleNext(request) { + try { + this.isStepping = true; + this.pausedAtBreakpoint = false; + await this.sendInspectorCommand("Debugger.stepOver", {}); + this.sendResponse(request); + } catch (error) { + this.isStepping = false; + this.sendResponse(request, false, {}, String(error)); + } + } + async handleStepIn(request) { + try { + this.isStepping = true; + this.pausedAtBreakpoint = false; + await this.sendInspectorCommand("Debugger.stepInto", {}); + this.sendResponse(request); + } catch (error) { + this.isStepping = false; + this.sendResponse(request, false, {}, String(error)); + } + } + async handleStepOut(request) { + try { + this.isStepping = true; + this.pausedAtBreakpoint = false; + await this.sendInspectorCommand("Debugger.stepOut", {}); + this.sendResponse(request); + } catch (error) { + this.isStepping = false; + this.sendResponse(request, false, {}, String(error)); + } + } + async handlePause(request) { + try { + await this.sendInspectorCommand("Debugger.pause", {}); + this.sendResponse(request); + } catch (error) { + this.sendResponse(request, false, {}, String(error)); + } + } + async handleDisconnect(request) { + this.running = false; + await this.cleanup(); + this.sendResponse(request); + } + async handleTerminate(request) { + this.running = false; + const shouldSendTerminated = !this.terminatedSent; + this.terminatedSent = true; + await this.cleanup(); + this.sendResponse(request); + if (shouldSendTerminated) { + this.sendEvent("terminated", this.scriptResult !== undefined ? { result: this.scriptResult } : {}); + } + } + async cleanup() { + if (this.inspectorWs) { + this.inspectorWs.close(); + this.inspectorWs = null; + } + if (this.process) { + this.process.kill(); + this.process = null; + } + if (this.tempFile) { + try { + await unlink(this.tempFile); + } catch {} + this.tempFile = null; + } + if (this.tempDir) { + try { + await rmdir(this.tempDir); + } catch {} + this.tempDir = null; + } + } + async handleRequest(request) { + const command = request.command || ""; + logger.debug(`Handling command: ${command}`); + const handlers = { + initialize: (req) => this.handleInitialize(req), + setBreakpoints: (req) => this.handleSetBreakpoints(req), + configurationDone: (req) => this.handleConfigurationDone(req), + launch: (req) => this.handleLaunch(req), + threads: (req) => this.handleThreads(req), + stackTrace: (req) => this.handleStackTrace(req), + scopes: (req) => this.handleScopes(req), + variables: (req) => this.handleVariables(req), + evaluate: (req) => this.handleEvaluate(req), + continue: (req) => this.handleContinue(req), + next: (req) => this.handleNext(req), + stepIn: (req) => this.handleStepIn(req), + stepOut: (req) => this.handleStepOut(req), + pause: (req) => this.handlePause(req), + disconnect: (req) => this.handleDisconnect(req), + terminate: (req) => this.handleTerminate(req) + }; + const handler = handlers[command]; + if (handler) { + await handler(request); + } else { + logger.warn(`Unhandled command: ${command}`); + this.sendResponse(request, false, {}, `Unsupported command: ${command}`); + } + } +} +var sessions = new Map; +var args = process.argv.slice(2); +var host = "localhost"; +var port = 5680; +var windmillPath; +for (let i = 0;i < args.length; i++) { + if (args[i] === "--host" && args[i + 1]) { + host = args[i + 1]; + i++; + } else if (args[i] === "--port" && args[i + 1]) { + port = parseInt(args[i + 1], 10); + i++; + } else if (args[i] === "--windmill" && args[i + 1]) { + windmillPath = args[i + 1]; + i++; + } +} +if (windmillPath) { + logger.info(`Windmill binary path: ${windmillPath}`); +} +logger.info(`Starting DAP WebSocket server on ws://${host}:${port}`); +var server = Bun.serve({ + hostname: host, + port, + fetch(req, server2) { + if (server2.upgrade(req)) { + return; + } + return new Response("DAP WebSocket Server for Bun/TypeScript", { status: 200 }); + }, + websocket: { + open(ws) { + logger.info("New client connected"); + const wsWrapper = { + send: (data) => ws.send(data), + close: () => ws.close(), + readyState: WebSocket.OPEN, + CONNECTING: WebSocket.CONNECTING, + OPEN: WebSocket.OPEN, + CLOSING: WebSocket.CLOSING, + CLOSED: WebSocket.CLOSED + }; + const session = new DebugSession(wsWrapper, { windmillPath }); + sessions.set(ws, session); + }, + async message(ws, message) { + const session = sessions.get(ws); + if (!session) + return; + try { + const data = JSON.parse(message); + logger.debug("Received:", JSON.stringify(data)); + if (data.type === "request") { + await session.handleRequest(data); + } + } catch (error) { + logger.error("Error handling message:", error); + } + }, + close(ws) { + logger.info("Client disconnected"); + sessions.delete(ws); + } + } +}); +logger.info(`Server started on ${server.url}`); +export { + DebugSession +}; diff --git a/debugger/test_dap_server.py b/debugger/test_dap_server.py new file mode 100644 index 0000000000..45e138f2a3 --- /dev/null +++ b/debugger/test_dap_server.py @@ -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()) diff --git a/debugger/test_dap_server_bun.ts b/debugger/test_dap_server_bun.ts new file mode 100644 index 0000000000..4b387d8136 --- /dev/null +++ b/debugger/test_dap_server_bun.ts @@ -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 = { + 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 + arguments?: Record +} + +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 void)[]> = new Map() + private terminatedResult: unknown = undefined + + constructor(url = 'ws://localhost:5680') { + this.url = url + } + + async connect(): Promise { + 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 + ): Promise { + 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 { + 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 { + 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 { + return this.sendRequest('setBreakpoints', { + source: { path }, + breakpoints: lines.map((line) => ({ line })) + }) + } + + async configurationDone(): Promise { + return this.sendRequest('configurationDone') + } + + async launch( + code: string, + callMain = false, + args: Record = {} + ): Promise { + return this.sendRequest('launch', { + code, + callMain, + args + }) + } + + async continue_(): Promise { + return this.sendRequest('continue', { threadId: 1 }) + } + + async next(): Promise { + return this.sendRequest('next', { threadId: 1 }) + } + + async getStackTrace(): Promise { + const response = await this.sendRequest('stackTrace', { + threadId: 1, + startFrame: 0, + levels: 20 + }) + return (response.body?.stackFrames as StackFrame[]) || [] + } + + async getScopes(frameId: number): Promise { + const response = await this.sendRequest('scopes', { frameId }) + return (response.body?.scopes as Scope[]) || [] + } + + async getVariables(variablesReference: number): Promise { + const response = await this.sendRequest('variables', { variablesReference }) + return (response.body?.variables as Variable[]) || [] + } + + async terminate(): Promise { + // 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 { + 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 = new Map() + const variableValuesAtBreakpoints: Map> = 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 = 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 { + 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) + }) +} diff --git a/debugger/test_debug_service.ts b/debugger/test_debug_service.ts new file mode 100644 index 0000000000..e9cebaa964 --- /dev/null +++ b/debugger/test_debug_service.ts @@ -0,0 +1,1188 @@ +#!/usr/bin/env bun +/** + * Test script for the unified DAP Debug Service. + * + * Tests both TypeScript/Bun and Python debugging endpoints. + * + * Run the service first: + * bun run dap_debug_service.ts + * + * Then run this test: + * bun run test_debug_service.ts + * + * To test with nsjail enabled: + * bun run dap_debug_service.ts --nsjail --nsjail-config /path/to/config.cfg + * bun run test_debug_service.ts --nsjail + * + * To test automatic dependency installation (autoinstall): + * bun run dap_debug_service.ts --windmill /path/to/windmill + * bun run test_debug_service.ts --test-autoinstall + */ + +import { spawn } from 'bun' + +const SERVICE_URL = 'ws://localhost:5679' +const TEST_NSJAIL = process.argv.includes('--nsjail') +const TEST_AUTOINSTALL = process.argv.includes('--test-autoinstall') + +const TS_TEST_CODE = `let counter = 0 +console.log("TS: Starting") + +export async function main(name: string) { + counter++ + console.log("TS: Hello " + name) + return { greeting: "Hello " + name, count: counter } +} +` + +const PYTHON_TEST_CODE = `import json + +counter = 0 + +def main(name): + global counter + counter += 1 + print(f"PY: Hello {name}") + return {"greeting": f"Hello {name}", "count": counter} +` + +const TS_ENV_TEST_CODE = `export async function main() { + const workspace = process.env.WM_WORKSPACE || "unknown" + const token = process.env.WM_TOKEN || "unknown" + console.log("ENV: Workspace=" + workspace) + return { workspace, token } +} +` + +const PYTHON_ENV_TEST_CODE = `import os + +def main(): + workspace = os.environ.get("WM_WORKSPACE", "unknown") + token = os.environ.get("WM_TOKEN", "unknown") + print(f"ENV: Workspace={workspace}") + return {"workspace": workspace, "token": token} +` + +// Test code for console.log with objects (should show object contents, not just "Object") +const TS_CONSOLE_OBJECT_CODE = `export async function main() { + const obj = { foo: "bar", count: 42 } + console.log(obj) + console.log("Done") + return obj +} +` + +// Test code for variable panel display - object values should show properties, not just "Object" +const TS_VARIABLE_OBJECT_CODE = `export async function main() { + const myObj = { name: "test", value: 123 } + const myArr = [1, 2, 3] + console.log("breakpoint here") + return { myObj, myArr } +} +` + +// Test code for Bun autoinstall - uses lodash which must be auto-installed +const TS_AUTOINSTALL_CODE = `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 code for Python autoinstall - uses requests which must be auto-installed +const PYTHON_AUTOINSTALL_CODE = `import requests + +def main(): + # Just verify that requests is importable and has expected attributes + version = requests.__version__ + print(f"Requests version: {version}") + return {"version": version, "imported": True} +` + +// Test code for Bun versioned imports - uses number-to-words@1 syntax +const TS_VERSIONED_IMPORT_CODE = `import { toWords } from "number-to-words@1" + +export async function main(num: number) { + const words = toWords(num) + console.log("Number in words:", words) + return { num, words } +} +` + +interface DAPMessage { + seq: number + type: 'request' | 'response' | 'event' + command?: string + event?: string + request_seq?: number + success?: boolean + message?: string + body?: Record + arguments?: Record +} + +class TestClient { + 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 void)[]>() + + async connect(endpoint: string): Promise { + const url = `${SERVICE_URL}${endpoint}` + console.log(` Connecting to ${url}...`) + + return new Promise((resolve, reject) => { + this.ws = new WebSocket(url) + + const timeout = setTimeout(() => { + reject(new Error('Connection timeout')) + }, 5000) + + this.ws.onopen = () => { + clearTimeout(timeout) + resolve() + } + + this.ws.onerror = () => { + clearTimeout(timeout) + reject(new Error('Connection failed')) + } + + this.ws.onmessage = (event) => { + this.handleMessage(event.data as string) + } + + this.ws.onclose = () => { + console.log(' 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 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 (error) { + console.error(' Failed to parse message:', error) + } + } + + private async sendRequest(command: string, args?: Record): Promise { + 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 { + 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 = {}, env: Record = {}) { + return this.sendRequest('launch', { code, callMain, args, env }) + } + + 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 + } + + async stackTrace(threadId: number = 1) { + return this.sendRequest('stackTrace', { threadId }) + } + + async scopes(frameId: number) { + return this.sendRequest('scopes', { frameId }) + } + + async variables(variablesReference: number) { + return this.sendRequest('variables', { variablesReference }) + } + + clearState() { + this.output = [] + this.result = undefined + this.events = [] + } +} + +async function testTypescriptEndpoint(): Promise { + console.log('\n=== Testing TypeScript/Bun Endpoint (/typescript) ===') + const client = new TestClient() + + try { + await client.connect('/typescript') + console.log(' Connected') + + await client.initialize() + console.log(' Initialized') + + const initP = client.waitForEvent('initialized') + + // Set breakpoint on line 6 (console.log inside main) + await client.setBreakpoints('/test.ts', [6]) + console.log(' Breakpoints set') + + await client.configurationDone() + await client.launch(TS_TEST_CODE, true, { name: 'World' }) + console.log(' Launched') + + await initP + + // Wait for stopped at breakpoint + const stopped = await client.waitForEvent('stopped', 15000) + console.log(` Stopped at line ${stopped.body?.line}, reason: ${stopped.body?.reason}`) + + // Check output at breakpoint + const outputAtBp = client.getOutput() + console.log(` Output at breakpoint: ${JSON.stringify(outputAtBp)}`) + + const hasHelloAtBp = outputAtBp.some(o => o.includes('Hello')) + if (hasHelloAtBp) { + console.log(' ❌ FAIL: "Hello" should not be printed at breakpoint') + return false + } + console.log(' ✓ Breakpoint stopped before "Hello" was printed') + + // Continue + await client.continue_() + console.log(' Continued') + + // Wait for termination + try { + await client.waitForEvent('terminated', 5000) + } catch {} + + await new Promise(r => setTimeout(r, 500)) + + // Check final output and result + const finalOutput = client.getOutput() + console.log(` Final output: ${JSON.stringify(finalOutput)}`) + + const result = client.getResult() + console.log(` Result: ${JSON.stringify(result)}`) + + // Verify + const hasStarting = finalOutput.some(o => o.includes('Starting')) + const hasHello = finalOutput.some(o => o.includes('Hello World')) + const hasCorrectResult = result && (result as any).greeting === 'Hello World' + + if (!hasStarting) { + console.log(' ❌ FAIL: Missing "Starting" output') + return false + } + if (!hasHello) { + console.log(' ❌ FAIL: Missing "Hello World" output') + return false + } + if (!hasCorrectResult) { + console.log(' ❌ FAIL: Incorrect result') + return false + } + + console.log(' ✓ All TypeScript tests passed!') + return true + } catch (error) { + console.log(` ❌ FAIL: ${error}`) + return false + } finally { + client.disconnect() + } +} + +async function testBunEndpointAlias(): Promise { + console.log('\n=== Testing Bun Endpoint Alias (/bun) ===') + const client = new TestClient() + + try { + await client.connect('/bun') + console.log(' Connected') + + await client.initialize() + console.log(' Initialized') + + const initP = client.waitForEvent('initialized') + await client.setBreakpoints('/test.ts', []) + await client.configurationDone() + await client.launch(TS_TEST_CODE, true, { name: 'Bun' }) + console.log(' Launched') + + await initP + + // Wait for termination + try { + await client.waitForEvent('terminated', 10000) + } catch {} + + await new Promise(r => setTimeout(r, 500)) + + const result = client.getResult() + console.log(` Result: ${JSON.stringify(result)}`) + + if (!result || (result as any).greeting !== 'Hello Bun') { + console.log(' ❌ FAIL: Incorrect result') + return false + } + + console.log(' ✓ Bun alias endpoint works!') + return true + } catch (error) { + console.log(` ❌ FAIL: ${error}`) + return false + } finally { + client.disconnect() + } +} + +async function testPythonEndpoint(): Promise { + console.log('\n=== Testing Python Endpoint (/python) ===') + const client = new TestClient() + + try { + await client.connect('/python') + console.log(' Connected') + + await client.initialize() + console.log(' Initialized') + + const initP = client.waitForEvent('initialized') + + // Set breakpoint on line 7 (print inside main) + await client.setBreakpoints('/test.py', [7]) + console.log(' Breakpoints set') + + await client.configurationDone() + await client.launch(PYTHON_TEST_CODE, true, { name: 'World' }) + console.log(' Launched') + + await initP + + // Wait for stopped at breakpoint + const stopped = await client.waitForEvent('stopped', 15000) + console.log(` Stopped at line ${stopped.body?.line}, reason: ${stopped.body?.reason}`) + + // Continue + await client.continue_() + console.log(' Continued') + + // Wait for termination + try { + await client.waitForEvent('terminated', 10000) + } catch {} + + await new Promise(r => setTimeout(r, 500)) + + // Check final output and result + const finalOutput = client.getOutput() + console.log(` Final output: ${JSON.stringify(finalOutput)}`) + + const result = client.getResult() + console.log(` Result: ${JSON.stringify(result)}`) + + // Verify + const hasHello = finalOutput.some(o => o.includes('Hello World')) + const hasCorrectResult = result && (result as any).greeting === 'Hello World' + + if (!hasHello) { + console.log(' ❌ FAIL: Missing "Hello World" output') + return false + } + if (!hasCorrectResult) { + console.log(' ❌ FAIL: Incorrect result') + return false + } + + console.log(' ✓ All Python tests passed!') + return true + } catch (error) { + console.log(` ❌ FAIL: ${error}`) + return false + } finally { + client.disconnect() + } +} + +async function testEnvVarsTypescript(): Promise { + console.log('\n=== Testing Environment Variables (TypeScript) ===') + const client = new TestClient() + + try { + await client.connect('/typescript') + console.log(' Connected') + + await client.initialize() + console.log(' Initialized') + + const initP = client.waitForEvent('initialized') + await client.setBreakpoints('/test.ts', []) + await client.configurationDone() + + // Pass env vars in launch request + await client.launch(TS_ENV_TEST_CODE, true, {}, { + WM_WORKSPACE: 'test-workspace', + WM_TOKEN: 'secret-token-123' + }) + console.log(' Launched with env vars') + + await initP + + // Wait for termination + try { + await client.waitForEvent('terminated', 10000) + } catch {} + + await new Promise(r => setTimeout(r, 500)) + + const output = client.getOutput() + console.log(` Output: ${JSON.stringify(output)}`) + + const result = client.getResult() as { workspace: string; token: string } | undefined + console.log(` Result: ${JSON.stringify(result)}`) + + if (!result || result.workspace !== 'test-workspace' || result.token !== 'secret-token-123') { + console.log(' ❌ FAIL: Env vars not passed correctly') + return false + } + + console.log(' ✓ TypeScript env vars test passed!') + return true + } catch (error) { + console.log(` ❌ FAIL: ${error}`) + return false + } finally { + client.disconnect() + } +} + +async function testEnvVarsPython(): Promise { + console.log('\n=== Testing Environment Variables (Python) ===') + const client = new TestClient() + + try { + await client.connect('/python') + console.log(' Connected') + + await client.initialize() + console.log(' Initialized') + + const initP = client.waitForEvent('initialized') + await client.setBreakpoints('/test.py', []) + await client.configurationDone() + + // Pass env vars in launch request + await client.launch(PYTHON_ENV_TEST_CODE, true, {}, { + WM_WORKSPACE: 'py-workspace', + WM_TOKEN: 'py-token-456' + }) + console.log(' Launched with env vars') + + await initP + + // Wait for termination + try { + await client.waitForEvent('terminated', 10000) + } catch {} + + await new Promise(r => setTimeout(r, 500)) + + const output = client.getOutput() + console.log(` Output: ${JSON.stringify(output)}`) + + const result = client.getResult() as { workspace: string; token: string } | undefined + console.log(` Result: ${JSON.stringify(result)}`) + + if (!result || result.workspace !== 'py-workspace' || result.token !== 'py-token-456') { + console.log(' ❌ FAIL: Env vars not passed correctly') + return false + } + + console.log(' ✓ Python env vars test passed!') + return true + } catch (error) { + console.log(` ❌ FAIL: ${error}`) + return false + } finally { + client.disconnect() + } +} + +async function testConsoleObjectOutput(): Promise { + console.log('\n=== Testing Console Object Output (TypeScript) ===') + const client = new TestClient() + + try { + await client.connect('/typescript') + console.log(' Connected') + + await client.initialize() + console.log(' Initialized') + + const initP = client.waitForEvent('initialized') + await client.setBreakpoints('/test.ts', []) + await client.configurationDone() + + await client.launch(TS_CONSOLE_OBJECT_CODE, true, {}) + console.log(' Launched') + + await initP + + // Wait for termination + try { + await client.waitForEvent('terminated', 10000) + } catch {} + + await new Promise(r => setTimeout(r, 500)) + + const output = client.getOutput() + console.log(` Output: ${JSON.stringify(output)}`) + + // Check that the object output contains actual property values, not just "Object" + const objectOutput = output.find(o => o.includes('foo')) + if (!objectOutput) { + console.log(' ❌ FAIL: Object should show property names like "foo"') + return false + } + + // Check that we see the property values + if (!objectOutput.includes('bar') && !objectOutput.includes('"bar"')) { + console.log(' ❌ FAIL: Object should show property values like "bar"') + return false + } + + // Make sure it's not just showing "Object" or "[object Object]" + if (output.some(o => o.trim() === 'Object' || o.includes('[object Object]'))) { + console.log(' ❌ FAIL: Should not just show "Object" or "[object Object]"') + return false + } + + console.log(' ✓ Console object output test passed!') + return true + } catch (error) { + console.log(` ❌ FAIL: ${error}`) + return false + } finally { + client.disconnect() + } +} + +async function testVariableObjectDisplay(): Promise { + console.log('\n=== Testing Variable Panel Object Display ===') + const client = new TestClient() + + try { + await client.connect('/typescript') + console.log(' Connected') + + await client.initialize() + console.log(' Initialized') + + const initP = client.waitForEvent('initialized') + + // Set breakpoint on line 4 (console.log("breakpoint here")) + await client.setBreakpoints('/test.ts', [4]) + console.log(' Breakpoint set on line 4') + + await client.configurationDone() + await client.launch(TS_VARIABLE_OBJECT_CODE, true, {}) + console.log(' Launched') + + await initP + + // Wait for stopped at breakpoint + const stopped = await client.waitForEvent('stopped', 15000) + console.log(` Stopped at line ${stopped.body?.line}, reason: ${stopped.body?.reason}`) + + // Get stack trace + const stackTraceResponse = await client.stackTrace() + const frames = stackTraceResponse.body?.stackFrames as Array<{ id: number; name: string }> | undefined + if (!frames || frames.length === 0) { + console.log(' ❌ FAIL: No stack frames returned') + return false + } + console.log(` Stack frames: ${frames.map(f => f.name).join(', ')}`) + + // Get scopes for the first frame + const scopesResponse = await client.scopes(frames[0].id) + const scopes = scopesResponse.body?.scopes as Array<{ name: string; variablesReference: number }> | undefined + if (!scopes || scopes.length === 0) { + console.log(' ❌ FAIL: No scopes returned') + return false + } + console.log(` Scopes: ${scopes.map(s => s.name).join(', ')}`) + + // Get variables from all scopes to find our local variables + let foundVariables: Array<{ name: string; value: string; type: string }> = [] + for (const scope of scopes) { + console.log(` Checking scope: ${scope.name} (variablesReference: ${scope.variablesReference})`) + const varsResponse = await client.variables(scope.variablesReference) + const scopeVars = varsResponse.body?.variables as Array<{ name: string; value: string; type: string }> | undefined + if (scopeVars && scopeVars.length > 0) { + console.log(` Found ${scopeVars.length} variables: ${scopeVars.map(v => v.name).join(', ')}`) + // Look for our specific variables + const myObj = scopeVars.find(v => v.name === 'myObj') + const myArr = scopeVars.find(v => v.name === 'myArr') + if (myObj || myArr) { + foundVariables = scopeVars + console.log(` Found target variables in this scope!`) + break + } + } + } + + if (foundVariables.length === 0) { + console.log(' ❌ FAIL: No relevant variables found in any scope') + return false + } + + const variables = foundVariables + console.log(` Variables: ${JSON.stringify(variables.map(v => ({ name: v.name, value: v.value })))}`) + + // Find myObj variable and check its value + const myObjVar = variables.find(v => v.name === 'myObj') + if (!myObjVar) { + console.log(' ❌ FAIL: myObj variable not found') + return false + } + + // Check that myObj shows properties, not just "Object" + const objValue = myObjVar.value + console.log(` myObj value: ${objValue}`) + + // It should NOT be just "Object" or "[object Object]" + if (objValue === 'Object' || objValue === '[object Object]' || objValue === '[Object]') { + console.log(' ❌ FAIL: myObj shows just "Object" instead of properties') + return false + } + + // It should contain property names or values + if (!objValue.includes('name') && !objValue.includes('test') && !objValue.includes('value') && !objValue.includes('123')) { + console.log(' ❌ FAIL: myObj should show properties like "name" or "value"') + return false + } + + // Find myArr variable and check its value + const myArrVar = variables.find(v => v.name === 'myArr') + if (myArrVar) { + console.log(` myArr value: ${myArrVar.value}`) + // Array should show contents, not just "Array" + if (myArrVar.value === 'Array' || myArrVar.value === '[object Array]') { + console.log(' ❌ FAIL: myArr shows just "Array" instead of contents') + return false + } + } + + // Continue and let it finish + await client.continue_() + + try { + await client.waitForEvent('terminated', 5000) + } catch {} + + console.log(' ✓ Variable panel object display test passed!') + return true + } catch (error) { + console.log(` ❌ FAIL: ${error}`) + return false + } finally { + client.disconnect() + } +} + +async function testHealthEndpoint(): Promise { + console.log('\n=== Testing Health Endpoint ===') + + try { + const response = await fetch(`http://localhost:5679/health`) + const data = await response.json() + + console.log(` Health response: ${JSON.stringify(data)}`) + + if (data.status !== 'ok') { + console.log(' ❌ FAIL: Health status not ok') + return false + } + + if (!data.endpoints || !data.endpoints.includes('/python') || !data.endpoints.includes('/typescript')) { + console.log(' ❌ FAIL: Missing endpoints in health response') + return false + } + + // If testing nsjail mode, verify nsjail is enabled in health response + if (TEST_NSJAIL) { + if (data.nsjail !== true) { + console.log(' ❌ FAIL: nsjail should be enabled but health shows false') + console.log(' → Make sure you started the server with: bun run dap_debug_service.ts --nsjail') + return false + } + console.log(' ✓ nsjail mode confirmed enabled') + } + + console.log(' ✓ Health endpoint works!') + return true + } catch (error) { + console.log(` ❌ FAIL: ${error}`) + return false + } +} + +/** + * Test Bun/TypeScript autoinstall via unified service. + * Requires the service to be started with --windmill pointing to the windmill binary. + */ +async function testBunAutoinstall(): Promise { + console.log('\n=== Testing Bun Autoinstall (TypeScript Endpoint) ===') + console.log(' Note: This test requires the service to be started with:') + console.log(' bun run dap_debug_service.ts --windmill /path/to/windmill') + const client = new TestClient() + + try { + await client.connect('/typescript') + console.log(' Connected') + + await client.initialize() + console.log(' Initialized') + + const initP = client.waitForEvent('initialized') + await client.setBreakpoints('/test.ts', []) + await client.configurationDone() + + // Launch with code that uses lodash (must be auto-installed) + await client.launch(TS_AUTOINSTALL_CODE, true, { items: [1, 2, 3, 4, 5] }) + console.log(' Launched with lodash import') + + await initP + + // Wait for termination with longer timeout (dependency installation can take time) + try { + await client.waitForEvent('terminated', 60000) + } catch {} + + await new Promise(r => setTimeout(r, 1000)) + + const output = client.getOutput() + console.log(` Output: ${JSON.stringify(output)}`) + + const result = client.getResult() as { sum?: number; max?: number } | undefined + console.log(` Result: ${JSON.stringify(result)}`) + + // Verify lodash functions worked correctly + const hasSum = output.some(o => o.includes('Sum:') && o.includes('15')) + const hasMax = output.some(o => o.includes('Max:') && o.includes('5')) + + if (!hasSum) { + console.log(' ❌ FAIL: Missing correct sum output (expected 15)') + return false + } + if (!hasMax) { + console.log(' ❌ FAIL: Missing correct max output (expected 5)') + return false + } + + if (!result || result.sum !== 15 || result.max !== 5) { + console.log(' ❌ FAIL: Incorrect result from lodash functions') + return false + } + + console.log(' ✓ Bun autoinstall test passed!') + return true + } catch (error) { + console.log(` ❌ FAIL: ${error}`) + console.log(' Make sure the service is started with --windmill flag') + return false + } finally { + client.disconnect() + } +} + +/** + * Test versioned imports (e.g., "number-to-words@1") via unified service. + * This syntax is supported by bun_executor in the backend but needs explicit handling in the debugger. + * Requires the service to be started with --windmill pointing to the windmill binary. + */ +async function testVersionedImports(): Promise { + console.log('\n=== Testing Versioned Imports (TypeScript) ===') + console.log(' Note: This test requires the service to be started with:') + console.log(' bun run dap_debug_service.ts --windmill /path/to/windmill') + const client = new TestClient() + + try { + await client.connect('/typescript') + console.log(' Connected') + + await client.initialize() + console.log(' Initialized') + + const initP = client.waitForEvent('initialized') + await client.setBreakpoints('/test.ts', []) + await client.configurationDone() + + // Launch with code that uses number-to-words@1 syntax (version specifier in import) + await client.launch(TS_VERSIONED_IMPORT_CODE, true, { num: 42 }) + console.log(' Launched with versioned import (number-to-words@1)') + + await initP + + // Wait for termination with longer timeout (dependency installation can take time) + try { + await client.waitForEvent('terminated', 60000) + } catch {} + + await new Promise(r => setTimeout(r, 1000)) + + const output = client.getOutput() + console.log(` Output: ${JSON.stringify(output)}`) + + const result = client.getResult() as { num?: number; words?: string } | undefined + console.log(` Result: ${JSON.stringify(result)}`) + + // Verify number-to-words converted 42 correctly + const hasWordsOutput = output.some(o => o.includes('Number in words:') && o.includes('forty')) + + if (!hasWordsOutput) { + console.log(' ❌ FAIL: Missing correct "forty" output from number-to-words') + return false + } + + if (!result || result.num !== 42) { + console.log(' ❌ FAIL: Incorrect num in result') + return false + } + + if (!result.words || !result.words.toLowerCase().includes('forty')) { + console.log(' ❌ FAIL: Incorrect words in result (expected "forty-two")') + return false + } + + console.log(' ✓ Versioned imports test passed!') + return true + } catch (error) { + console.log(` ❌ FAIL: ${error}`) + console.log(' Make sure the service is started with --windmill flag') + return false + } finally { + client.disconnect() + } +} + +/** + * Test Python autoinstall via unified service. + * Requires the service to be started with --windmill pointing to the windmill binary. + */ +async function testPythonAutoinstall(): Promise { + console.log('\n=== Testing Python Autoinstall ===') + console.log(' Note: This test requires the service to be started with:') + console.log(' bun run dap_debug_service.ts --windmill /path/to/windmill') + const client = new TestClient() + + try { + await client.connect('/python') + console.log(' Connected') + + await client.initialize() + console.log(' Initialized') + + const initP = client.waitForEvent('initialized') + await client.setBreakpoints('/test.py', []) + await client.configurationDone() + + // Launch with code that uses requests (must be auto-installed) + await client.launch(PYTHON_AUTOINSTALL_CODE, true, {}) + console.log(' Launched with requests import') + + await initP + + // Wait for termination with longer timeout (dependency installation can take time) + try { + await client.waitForEvent('terminated', 120000) + } catch {} + + await new Promise(r => setTimeout(r, 1000)) + + const output = client.getOutput() + console.log(` Output: ${JSON.stringify(output)}`) + + const result = client.getResult() as { version?: string; imported?: boolean } | undefined + console.log(` Result: ${JSON.stringify(result)}`) + + // Verify requests was imported successfully + const hasVersionOutput = output.some(o => o.includes('Requests version:')) + + if (!hasVersionOutput) { + console.log(' ❌ FAIL: Missing requests version output') + return false + } + + if (!result || result.imported !== true) { + console.log(' ❌ FAIL: requests module was not imported successfully') + return false + } + + if (!result.version || typeof result.version !== 'string') { + console.log(' ❌ FAIL: requests version not returned correctly') + return false + } + + console.log(` ✓ Python autoinstall test passed! (requests ${result.version})`) + return true + } catch (error) { + console.log(` ❌ FAIL: ${error}`) + console.log(' Make sure the service is started with --windmill flag') + return false + } finally { + client.disconnect() + } +} + +async function checkNsjailAvailable(): Promise { + try { + const proc = spawn({ + cmd: ['nsjail', '--version'], + stdout: 'pipe', + stderr: 'pipe' + }) + const exitCode = await proc.exited + return exitCode === 0 + } catch { + return false + } +} + +async function main() { + console.log('='.repeat(60)) + console.log('DAP Debug Service Tests') + if (TEST_NSJAIL) { + console.log('Mode: NSJAIL ENABLED') + const nsjailAvailable = await checkNsjailAvailable() + if (!nsjailAvailable) { + console.log('\n⚠️ WARNING: nsjail not found in PATH') + console.log(' Tests will verify nsjail configuration is passed correctly,') + console.log(' but actual sandboxed execution cannot be tested.') + } + } else if (TEST_AUTOINSTALL) { + console.log('Mode: AUTOINSTALL TEST') + console.log('Make sure the service is started with: --windmill /path/to/windmill') + } else { + console.log('Mode: Standard (no nsjail)') + } + console.log('='.repeat(60)) + + let passed = 0 + let failed = 0 + + // Test health endpoint + if (await testHealthEndpoint()) { + passed++ + } else { + failed++ + } + + // Test TypeScript endpoint with breakpoints + if (await testTypescriptEndpoint()) { + passed++ + } else { + failed++ + } + + // Test Bun alias endpoint + if (await testBunEndpointAlias()) { + passed++ + } else { + failed++ + } + + // Test Python endpoint + if (await testPythonEndpoint()) { + passed++ + } else { + failed++ + } + + // Test environment variables (TypeScript) + if (await testEnvVarsTypescript()) { + passed++ + } else { + failed++ + } + + // Test environment variables (Python) + if (await testEnvVarsPython()) { + passed++ + } else { + failed++ + } + + // Test console object output formatting + if (await testConsoleObjectOutput()) { + passed++ + } else { + failed++ + } + + // Test variable panel object display + if (await testVariableObjectDisplay()) { + passed++ + } else { + failed++ + } + + // Autoinstall tests (only run when --test-autoinstall is passed) + if (TEST_AUTOINSTALL) { + // Test Bun autoinstall + if (await testBunAutoinstall()) { + passed++ + } else { + failed++ + } + + // Test Python autoinstall + if (await testPythonAutoinstall()) { + passed++ + } else { + failed++ + } + + // Test versioned imports (e.g., "number-to-words@1") + if (await testVersionedImports()) { + passed++ + } else { + failed++ + } + } + + // Summary + console.log('\n' + '='.repeat(60)) + console.log(`TEST SUMMARY: ${passed} passed, ${failed} failed`) + console.log('='.repeat(60)) + + if (failed > 0) { + console.log('\nNote: Python endpoint requires websockets package.') + console.log('Run: pip install websockets') + process.exit(1) + } else { + console.log('\n✓ All tests passed!') + process.exit(0) + } +} + +main().catch((error) => { + console.error('Test runner failed:', error) + process.exit(1) +}) diff --git a/docker-compose.yml b/docker-compose.yml index df118357d2..b5c7abcedd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docker/DockerfileExtra b/docker/DockerfileExtra new file mode 100644 index 0000000000..f6b1a0edb5 --- /dev/null +++ b/docker/DockerfileExtra @@ -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"] diff --git a/docker/entrypoint-extra.sh b/docker/entrypoint-extra.sh new file mode 100644 index 0000000000..009559711b --- /dev/null +++ b/docker/entrypoint-extra.sh @@ -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 diff --git a/docker/test_windmill_extra.ts b/docker/test_windmill_extra.ts new file mode 100644 index 0000000000..25b7916598 --- /dev/null +++ b/docker/test_windmill_extra.ts @@ -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): Promise { + 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 { + // 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 { + // 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 { + 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 { + // 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 { + 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 + arguments?: Record +} + +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 void)[]>() + + async connect(endpoint: string): Promise { + 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): Promise { + 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 { + 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 = {}) { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + log('Waiting for services to be ready...') + const start = Date.now() + + const checkService = async (name: string, url: string): Promise => { + 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) +}) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ff749e4fa6..8430575a85 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -51,7 +51,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", @@ -836,7 +835,6 @@ "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -848,7 +846,6 @@ "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -859,7 +856,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1349,7 +1345,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.0.tgz", "integrity": "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1551,7 +1546,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1568,7 +1562,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1585,7 +1578,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1602,7 +1594,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1619,7 +1610,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1636,7 +1626,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1653,7 +1642,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1670,7 +1658,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1687,7 +1674,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1704,7 +1690,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1721,7 +1706,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1738,7 +1722,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1755,7 +1738,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2376,7 +2358,6 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3051,474 +3032,6 @@ } } }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@vitest/browser-playwright/node_modules/@vitest/mocker": { "version": "4.0.15", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.15.tgz", @@ -3546,630 +3059,6 @@ } } }, - "node_modules/@vitest/browser-playwright/node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/@vitest/browser-playwright/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@vitest/browser-playwright/node_modules/vite": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", - "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@vitest/browser/node_modules/@vitest/mocker": { "version": "4.0.15", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.15.tgz", @@ -4197,162 +3086,6 @@ } } }, - "node_modules/@vitest/browser/node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" - } - }, - "node_modules/@vitest/browser/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/@vitest/browser/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@vitest/browser/node_modules/vite": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", - "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, "node_modules/@vitest/expect": { "version": "4.0.15", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.15.tgz", @@ -5999,12 +4732,6 @@ "url": "https://opencollective.com/date-fns" } }, - "node_modules/debounce-promise": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/debounce-promise/-/debounce-promise-3.1.2.tgz", - "integrity": "sha512-rZHcgBkbYavBeD9ej6sP56XfG53d51CD4dnaw989YX/nZ/ZJfgRx/9ePKmTNiUiyQvh4mtrMoS3OAWW+yoYtpg==", - "license": "MIT" - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -7798,23 +6525,6 @@ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, - "node_modules/graphql-language-service": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/graphql-language-service/-/graphql-language-service-5.5.0.tgz", - "integrity": "sha512-9EvWrLLkF6Y5e29/2cmFoAO6hBPPAZlCyjznmpR11iFtRydfkss+9m6x+htA8h7YznGam+TtJwS6JuwoWWgb2Q==", - "license": "MIT", - "dependencies": { - "debounce-promise": "^3.1.2", - "nullthrows": "^1.0.0", - "vscode-languageserver-types": "^3.17.1" - }, - "bin": { - "graphql": "dist/temp-bin.js" - }, - "peerDependencies": { - "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0" - } - }, "node_modules/hammerjs": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", @@ -8486,7 +7196,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -8922,7 +7632,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8943,7 +7652,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8964,7 +7672,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8985,7 +7692,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9006,7 +7712,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9027,7 +7732,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9048,7 +7752,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9069,7 +7772,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9090,7 +7792,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9111,7 +7812,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9132,7 +7832,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -10405,21 +9104,6 @@ "@codingame/monaco-vscode-api": "24.2.0" } }, - "node_modules/monaco-graphql": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/monaco-graphql/-/monaco-graphql-1.7.3.tgz", - "integrity": "sha512-6LAIcg/vT2NGLjHnT+5iIZONsZCaCuz2orbg7qD/u4Ry9R7rDotLh0HAzIF/yKdzEA5fTZC+TofSx2O+Zi+0ow==", - "license": "MIT", - "dependencies": { - "graphql-language-service": "^5.5.0", - "picomatch-browser": "^2.2.6" - }, - "peerDependencies": { - "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", - "monaco-editor": ">= 0.20.0 < 0.53", - "prettier": "^2.8.0 || ^3.0.0" - } - }, "node_modules/monaco-languageclient": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/monaco-languageclient/-/monaco-languageclient-10.5.0.tgz", @@ -10696,12 +9380,6 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "node_modules/nullthrows": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", - "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", - "license": "MIT" - }, "node_modules/nypm": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.5.4.tgz", @@ -11189,18 +9867,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/picomatch-browser": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/picomatch-browser/-/picomatch-browser-2.2.6.tgz", - "integrity": "sha512-0ypsOQt9D4e3hziV8O4elD9uN0z/jtUEfxVRtNaAAtXIyUx9m/SzlO020i8YNL2aL/E6blOvvHQcin6HZlFy/w==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/pify": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", @@ -12091,6 +10757,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -13802,21 +12469,6 @@ } } }, - "node_modules/svelte-check/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/svelte-eslint-parser": { "version": "0.43.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index d09ab3860f..c6046ea9f5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index 73a90fa875..afaf00d831 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -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) { diff --git a/frontend/src/lib/components/ModulePreview.svelte b/frontend/src/lib/components/ModulePreview.svelte index 975d715318..a9b769a4a5 100644 --- a/frontend/src/lib/components/ModulePreview.svelte +++ b/frontend/src/lib/components/ModulePreview.svelte @@ -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') @@ -60,26 +62,28 @@ > {/if} -
- {#if testIsLoading} - - {:else} - - {/if} -
+ {#if !hideRunButton} +
+ {#if testIsLoading} + + {:else} + + {/if} +
+ {/if} diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 2a1c3cdb27..dff30ba698 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -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([]) + // Debug mode state + const DEBUG_BETA_WARNING_KEY = 'debug_beta_warning_confirmed' + let showDebugBetaWarning = $state(false) + let debugMode = $state(false) + let debugBreakpoints = new SvelteSet() + 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 | 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() + 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 { + 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 { + 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 { + 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 { + if (!dapClient) return + await dapClient.continue_() + } + + async function stepOver(): Promise { + if (!dapClient) return + await dapClient.stepOver() + } + + async function stepIn(): Promise { + if (!dapClient) return + await dapClient.stepIn() + } + + async function stepOut(): Promise { + 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 @@ /> + + +
+
+
+ +
+
+
+

The Debug feature is currently in beta. You may encounter unexpected behavior or limitations.

+

By continuing, you acknowledge that this feature is experimental.

+
+
+ {#snippet actions()} + + {/snippet} +
+
{#if args} @@ -678,6 +1066,24 @@
{/if} + {#if debugMode && isDebuggableScript} +
+ +
+ {/if} +
- {#if testIsLoading} - - {:else} - {@const disableTriggerButton = customUi?.previewPanel?.disableTriggerButton === true} -
- - {#if !disableTriggerButton} - - {/if} -
+ {:else} + {@const disableTriggerButton = customUi?.previewPanel?.disableTriggerButton === true} +
+ + {#if !disableTriggerButton} + + {/if} +
+ {/if} {/if}
- + {#if jsonView}
- {#if scriptProgress} + {#if scriptProgress && !debugMode}
{/snippet} + {#snippet customResultPanel()} + + {/snippet}
@@ -820,11 +1253,37 @@ {#if assets?.length} {/if} + {#if isDebuggableScript} + + {/if} + {#if showDebugPanel && !showDebugConsole} + + {/if} {#if lang === 'ansible' && hasDelegateToGitRepo}
- {#key lang} - { - 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} - /> - { - showHistoryDrawer = true - } - }, - { - text: 'Quit diff mode', - onClick: () => { - hideDiffMode() - }, - color: 'red' - } - ] - : []} - /> - {/key} + {#if debugConsoleVisible} + + + + {@render editorPane()} + + + (showDebugConsole = false)} + workspace={$workspaceStore} + jobId={debugSessionJobId ?? undefined} + /> + + + {:else} + +
+ {@render editorPane()} +
+ {/if} {/snippet} +{#snippet editorPane()} + {#key lang} + { + 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} + /> + { + showHistoryDrawer = true + } + }, + { + text: 'Quit diff mode', + onClick: () => { + hideDiffMode() + }, + color: 'red' + } + ] + : []} + /> + {/key} +{/snippet} + + + diff --git a/frontend/src/lib/components/debug/DebugConsole.svelte b/frontend/src/lib/components/debug/DebugConsole.svelte new file mode 100644 index 0000000000..0694120619 --- /dev/null +++ b/frontend/src/lib/components/debug/DebugConsole.svelte @@ -0,0 +1,295 @@ + + + + +
+ +
+ Console +
+ + {#if onClose} + + {/if} +
+
+ + +
+ {#if history.length === 0} +
+ Evaluate expressions in the current scope. Use ↑↓ for history. +
+ {/if} + {#each history as entry (entry.id)} +
+ {#if entry.type === 'input'} + > + {entry.content} + {:else if entry.type === 'output'} + > + {formatValue(entry.content, 'output')} + {:else} + + {entry.content} + {/if} +
+ {/each} +
+ + +
+ > + + {#if isEvaluating} +
+ {/if} +
+
diff --git a/frontend/src/lib/components/debug/DebugPanel.svelte b/frontend/src/lib/components/debug/DebugPanel.svelte new file mode 100644 index 0000000000..e9dc7d08b9 --- /dev/null +++ b/frontend/src/lib/components/debug/DebugPanel.svelte @@ -0,0 +1,120 @@ + + +
+ +
+
+ + Variables +
+
+
+ + +
+
+
+ {#if scopes.length === 0} +
No variables
+ {:else if filteredVariables.length === 0} +
No matches
+ {:else} + {#each filteredVariables as { scope, variable } (scope + variable.name)} + + {/each} + {/if} +
+
+ + +
+
+ + Call Stack +
+
+ {#if stackFrames.length === 0} +
No call stack
+ {:else} + {#each stackFrames as frame (frame.id)} + + {/each} + {/if} +
+
+
diff --git a/frontend/src/lib/components/debug/DebugToolbar.svelte b/frontend/src/lib/components/debug/DebugToolbar.svelte new file mode 100644 index 0000000000..5d5858049c --- /dev/null +++ b/frontend/src/lib/components/debug/DebugToolbar.svelte @@ -0,0 +1,189 @@ + + +
+
+ + Debug + {#if connected} + + {:else} + + {/if} +
+ +
+ + {#if !connected || (!running && !stopped)} + + {:else} + + {/if} + +
+ + +
+
+ +
+ + + {/if} + + {#if running && !stopped} + + Running... + + {:else if stopped} + Paused + {/if} +
+ +{#if breakpointCount === 0 && !running && !stopped} +
+ + + No breakpoints set - click in the gutter to add one + +
+{/if} diff --git a/frontend/src/lib/components/debug/DebugVariableViewer.svelte b/frontend/src/lib/components/debug/DebugVariableViewer.svelte new file mode 100644 index 0000000000..0ba2bce4a5 --- /dev/null +++ b/frontend/src/lib/components/debug/DebugVariableViewer.svelte @@ -0,0 +1,128 @@ + + +
+
+ {#if isExpandable} + + {:else} + + {/if} + +
+ {variable.name} + = + + {#if hasChildren && expanded} + {variable.type === 'object' || variable.value.startsWith('{') ? '{' : '['} + {:else} + {formatValue(variable.value, variable.type)} + {/if} + + {#if variable.type && !hasChildren} + ({variable.type}) + {/if} +
+
+ + {#if expanded && hasChildren} +
+ {#if children.length === 0 && loaded} +
+ No properties +
+ {:else} + {#each children as child (child.name)} + + {/each} + {/if} +
+
+ {variable.type === 'object' || variable.value.startsWith('{') ? '}' : ']'} +
+ {/if} +
diff --git a/frontend/src/lib/components/debug/MonacoDebugger.svelte b/frontend/src/lib/components/debug/MonacoDebugger.svelte new file mode 100644 index 0000000000..948bb400ee --- /dev/null +++ b/frontend/src/lib/components/debug/MonacoDebugger.svelte @@ -0,0 +1,462 @@ + + +
+ + + {#if $debugState.connected} + + {/if} +
+ + diff --git a/frontend/src/lib/components/debug/dapClient.ts b/frontend/src/lib/components/debug/dapClient.ts new file mode 100644 index 0000000000..8e2dd5b5d1 --- /dev/null +++ b/frontend/src/lib/components/debug/dapClient.ts @@ -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 + arguments?: Record +} + +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 + breakpoints: Map + 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({ ...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 { + 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): Promise { + 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 | 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 { + 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 { + 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 { + await this.sendRequest('configurationDone') + } + + /** + * Launch a script for debugging. + */ + async launch(options: { + program?: string + code?: string + args?: string[] | Record + cwd?: string + callMain?: boolean + env?: Record + // JWT token for audit/authorization (signed by backend) + token?: string + }): Promise { + 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 { + await this.sendRequest('continue', { threadId: 1 }) + debugState.update((s) => ({ ...s, stopped: false, running: true })) + } + + /** + * Step over (next). + */ + async stepOver(): Promise { + await this.sendRequest('next', { threadId: 1 }) + } + + /** + * Step into. + */ + async stepIn(): Promise { + await this.sendRequest('stepIn', { threadId: 1 }) + } + + /** + * Step out. + */ + async stepOut(): Promise { + await this.sendRequest('stepOut', { threadId: 1 }) + } + + /** + * Pause execution. + */ + async pause(): Promise { + await this.sendRequest('pause', { threadId: 1 }) + } + + /** + * Terminate the debug session. + */ + async terminate(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/frontend/src/lib/components/debug/debugUtils.ts b/frontend/src/lib/components/debug/debugUtils.ts new file mode 100644 index 0000000000..ec9c12cfb4 --- /dev/null +++ b/frontend/src/lib/components/debug/debugUtils.ts @@ -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> { + if (!workspace) { + return {} + } + + try { + const variables = await VariableService.listContextualVariables({ workspace }) + const envVars: Record = {} + 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' + } +} diff --git a/frontend/src/lib/components/debug/index.ts b/frontend/src/lib/components/debug/index.ts new file mode 100644 index 0000000000..e7226a39ed --- /dev/null +++ b/frontend/src/lib/components/debug/index.ts @@ -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 + * + * + * + * + * + * + * + * ``` + */ + +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) +} diff --git a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte index 213dd46b59..9d3b81ec61 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte @@ -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() + let breakpointDecorations: string[] = $state([]) + let currentLineDecoration: string[] = $state([]) + let dapClient = $state | 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() + 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 { + 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 { + 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>((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 { + 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 { + if (!dapClient) return + await dapClient.continue_() + } + + async function stepOver(): Promise { + if (!dapClient) return + await dapClient.stepOver() + } + + async function stepIn(): Promise { + if (!dapClient) return + await dapClient.stepIn() + } + + async function stepOut(): Promise { + 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() + } + }) @@ -464,54 +810,149 @@ {#if assets?.length} {/if} + {#if isDebuggableScript} + + {/if} + {#if showDebugPanel && !showDebugConsole} + + {/if} -
- { - selected = 'test' - if (selectedId == flowModule.id) { - if (flowModule.value.type === 'rawscript' && editor) { - flowModule.value.content = editor.getCode() + {#if debugConsoleVisible} + + +
+ { + 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} + /> +
+
+ + (showDebugConsole = false)} + workspace={$workspaceStore} + jobId={debugSessionJobId ?? undefined} + /> + +
+ {:else} +
+ { + 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} - /> -
+ }} + 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} + /> +
+ {/if} {:else if selected === 'test'} + {#if debugMode && isDebuggableScript} +
+ +
+ {/if} {:else if selected === 'advanced'} @@ -922,28 +1382,78 @@ > {/if} - { - 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} + + + + + + + + {#if hasDebugResult} +
+ +
+ {:else} +
+ {#if $debugState.running && !$debugState.stopped} + Running... + {:else if $debugState.stopped} + Paused at breakpoint + {:else} + Waiting for debug session + {/if} +
+ {/if} +
+
+
+ + + +
+ {:else if debugMode && isDebuggableScript} +
+ Click "Debug" in the toolbar to start debugging +
+ {:else} + { + 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} {/if} @@ -956,3 +1466,20 @@ {:else} Incorrect flow module type {/if} + + +
+
+
+ +
+
+
+

The Debug feature is currently in beta. You may encounter unexpected behavior or limitations.

+

By continuing, you acknowledge that this feature is experimental.

+
+
+ {#snippet actions()} + + {/snippet} +
diff --git a/frontend/src/lib/components/raw_apps/RawAppInlineScriptEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppInlineScriptEditor.svelte index 184102a429..44c0ae1811 100644 --- a/frontend/src/lib/components/raw_apps/RawAppInlineScriptEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppInlineScriptEditor.svelte @@ -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() + let breakpointDecorations: string[] = $state([]) + let currentLineDecoration: string[] = $state([]) + let dapClient = $state | 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() + 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 { + 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 { + 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>((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 { + 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 { + if (!dapClient) return + await dapClient.continue_() + } + + export async function stepOver(): Promise { + if (!dapClient) return + await dapClient.stepOver() + } + + export async function stepIn(): Promise { + if (!dapClient) return + await dapClient.stepIn() + } + + export async function stepOut(): Promise { + 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(null) // Track pending selection during mouse drag @@ -303,40 +656,119 @@ {#if inlineScript.assets?.length} {/if} + {#if isDebuggableScript} + + {/if} + {#if showDebugPanel && !showDebugConsole} + + {/if} - onRun()} - bind:websocketAlive - rawAppRunnableKey={id} - on:change={async (e) => { - if (inlineScript) { - if (inlineScript.lock != undefined) { - inlineScript.lock = undefined + {#if debugConsoleVisible} + + + 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} + /> + + + (showDebugConsole = false)} + workspace={$workspaceStore} + jobId={debugSessionJobId ?? undefined} + /> + + + {:else} + 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} {/if} + + +
+
+
+ +
+
+
+

The Debug feature is currently in beta. You may encounter unexpected behavior or limitations.

+

By continuing, you acknowledge that this feature is experimental.

+
+
+ {#snippet actions()} + + {/snippet} +
diff --git a/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte b/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte index c10988f1c4..4a1d383bf0 100644 --- a/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte @@ -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) { if (args == undefined) { args = {} @@ -127,6 +161,7 @@ {#if isRunnableByName(runnable)} dispatch('createScriptFromInlineScript', runnable)} {id} bind:inlineScript={runnable.inlineScript} @@ -195,6 +230,24 @@
No inputs
{/if} {:else if selectedTab == 'test'} + {#if debugMode && isDebuggableScript} +
+ 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()} + /> +
+ {/if} @@ -210,7 +263,57 @@ - + {#if showDebugPanel || hasDebugResult} + + + + + + + + {#if hasDebugResult} +
+ +
+ {:else} +
+ {#if $debugState.running && !$debugState.stopped} + Running... + {:else if $debugState.stopped} + Paused at breakpoint + {:else} + Waiting for debug session + {/if} +
+ {/if} +
+
+
+ + + +
+ {:else if debugMode && isDebuggableScript} +
+ Click "Debug" in the toolbar to start debugging +
+ {:else} + + {/if}
diff --git a/frontend/src/lib/components/scriptEditor/LogPanel.svelte b/frontend/src/lib/components/scriptEditor/LogPanel.svelte index 3c4f1df466..be9ddfcff0 100644 --- a/frontend/src/lib/components/scriptEditor/LogPanel.svelte +++ b/frontend/src/lib/components/scriptEditor/LogPanel.svelte @@ -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 @@
{@render children?.()} - {#if previewJob != undefined && (previewJob.result_stream || previewJob.result)} + {#if showCustomResultPanel && customResultPanel} +
+ {@render customResultPanel()} +
+ {:else if previewJob != undefined && (previewJob.result_stream || previewJob.result)}
$defaultScripts?.hidden == undefined || !$defaultScripts.hidden.includes(x) )?.[0] ?? 'bun') as ScriptLang, kind: 'script' diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 142c75f500..614299ad1a 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -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' }, diff --git a/multiplayer/.gitignore b/multiplayer/.gitignore new file mode 100644 index 0000000000..c2658d7d1b --- /dev/null +++ b/multiplayer/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/multiplayer/package-lock.json b/multiplayer/package-lock.json new file mode 100644 index 0000000000..0d8c1985ce --- /dev/null +++ b/multiplayer/package-lock.json @@ -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" + } + } + } +} diff --git a/multiplayer/package.json b/multiplayer/package.json new file mode 100644 index 0000000000..aa60cab0dd --- /dev/null +++ b/multiplayer/package.json @@ -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" + } +} diff --git a/multiplayer/server.mjs b/multiplayer/server.mjs new file mode 100644 index 0000000000..86ac300d71 --- /dev/null +++ b/multiplayer/server.mjs @@ -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}`) +})