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