From 43f1edb992473c42a9af821ce215bdd6dc346bd7 Mon Sep 17 00:00:00 2001 From: Adam Chalmers Date: Thu, 15 Oct 2026 12:03:42 -0500 Subject: [PATCH 1/6] Add more objects --- rust/Cargo.lock | 5 +- rust/Cargo.toml | 2 +- rust/kcl-lib/Cargo.toml | 1 + rust/kcl-lib/src/engine/engine_manager.rs | 2 +- .../src/engine/engine_manager/ws_transport.rs | 7 +- rust/kcl-lib/src/execution/id_generator.rs | 18 +- rust/kcl-lib/src/execution/mod.rs | 2 + rust/kcl-lib/src/execution/remote.rs | 654 ++++++++++++++++++ rust/kcl-python-bindings/Cargo.toml | 1 + rust/kcl-python-bindings/src/kcl_session.rs | 22 +- rust/kcl-python-bindings/src/lib.rs | 47 +- rust/kcl-python-bindings/tests/tests.py | 73 ++ 12 files changed, 791 insertions(+), 43 deletions(-) create mode 100644 rust/kcl-lib/src/execution/remote.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 81b5a9ddc2a..12bea36f99c 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -3179,6 +3179,7 @@ dependencies = [ "http 1.5.0", "indexmap 2.14.2", "kcl-api", + "kcl-error", "kittycad-measurements", "kittycad-modeling-cmds-macros", "kittycad-point 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -3200,8 +3201,6 @@ dependencies = [ [[package]] name = "kittycad-modeling-cmds-macros" version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50c9f1366e47999291754861f2362774f1c98bf478819d3788e70b978381e230" dependencies = [ "kittycad-modeling-cmds-macros-impl", "proc-macro2", @@ -3212,8 +3211,6 @@ dependencies = [ [[package]] name = "kittycad-modeling-cmds-macros-impl" version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "101a9b33a3af1c80223b1e34d8d4a28c7e09b61b43fa8ad643efba35bd2cc10e" dependencies = [ "proc-macro2", "quote", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 9cdb1693f61..e15a8b6261b 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -117,4 +117,4 @@ kcl-error = { path = "kcl-error" } #Example: how to point modeling-app at a different repo (e.g. a branch or a local clone) # [patch.crates-io] # ezpz = { git = "https://github.com/KittyCAD/ezpz.git", branch = "david/circle-circle-tangent" } -# kittycad-modeling-cmds = { path = "../../modeling-api/modeling-cmds" } +kittycad-modeling-cmds = { path = "../../modeling-api/modeling-cmds" } diff --git a/rust/kcl-lib/Cargo.toml b/rust/kcl-lib/Cargo.toml index 41d01ee0b51..920fc486636 100644 --- a/rust/kcl-lib/Cargo.toml +++ b/rust/kcl-lib/Cargo.toml @@ -37,6 +37,7 @@ dhat-heap = ["dep:dhat"] # This is used for editor extensions that use the lsp server. disable-println = [] engine = [] +remote-execution = ["engine", "kittycad-modeling-cmds/exec-kcl"] pyo3 = ["dep:pyo3", "dep:pyo3-stub-gen"] # Test-only compiler hooks used by the standalone language-server crate. lsp-test-util = [] diff --git a/rust/kcl-lib/src/engine/engine_manager.rs b/rust/kcl-lib/src/engine/engine_manager.rs index abf82ca1eee..cd2eeb3c592 100644 --- a/rust/kcl-lib/src/engine/engine_manager.rs +++ b/rust/kcl-lib/src/engine/engine_manager.rs @@ -753,7 +753,7 @@ impl EngineManager { }) } - fn parse_websocket_response( + pub(crate) fn parse_websocket_response( &self, response: WebSocketResponse, source_range: SourceRange, diff --git a/rust/kcl-lib/src/engine/engine_manager/ws_transport.rs b/rust/kcl-lib/src/engine/engine_manager/ws_transport.rs index 724d989f89d..d52910be5e0 100644 --- a/rust/kcl-lib/src/engine/engine_manager/ws_transport.rs +++ b/rust/kcl-lib/src/engine/engine_manager/ws_transport.rs @@ -374,13 +374,16 @@ impl WebSocketTransport { Some(ToEngineReq { req, request_sent }) => { // Decide whether to send as binary or text, // then send to the engine. - let res = if matches!( + let binary = matches!( &req, WebSocketRequest::ModelingCmdReq(ModelingCmdReq { cmd: ModelingCmd::ImportFiles { .. }, cmd_id: _, }) - ) { + ); + #[cfg(feature = "remote-execution")] + let binary = binary || matches!(&req, WebSocketRequest::ExecKclProject { .. }); + let res = if binary { Self::inner_send_to_engine_binary(req, &mut tcp_write).await } else { Self::inner_send_to_engine(req, &mut tcp_write).await diff --git a/rust/kcl-lib/src/execution/id_generator.rs b/rust/kcl-lib/src/execution/id_generator.rs index 4edf7877df5..0d13f75509f 100644 --- a/rust/kcl-lib/src/execution/id_generator.rs +++ b/rust/kcl-lib/src/execution/id_generator.rs @@ -10,11 +10,25 @@ const NAMESPACE_KCL: uuid::Uuid = uuid::uuid!("8bda3118-75eb-58c7-a866-bef1dcb49 pub struct IdGenerator { module_id: Option, next_id: u64, + namespace: Option, } impl IdGenerator { pub fn new(module_id: Option) -> Self { - Self { module_id, next_id: 0 } + Self { + module_id, + next_id: 0, + namespace: None, + } + } + + /// Follow-up commands must not reuse IDs allocated by the remote interpreter. + #[cfg(all(feature = "remote-execution", not(target_arch = "wasm32")))] + pub(super) fn remote_session() -> Self { + Self { + namespace: Some(uuid::Uuid::new_v4()), + ..Self::default() + } } pub fn next_uuid(&mut self) -> uuid::Uuid { @@ -25,7 +39,7 @@ impl IdGenerator { self.module_id.map(|id| id.to_string()).unwrap_or("none".to_string()), next_id ); - let next_uuid = uuid::Uuid::new_v5(&NAMESPACE_KCL, next.as_bytes()); + let next_uuid = uuid::Uuid::new_v5(self.namespace.as_ref().unwrap_or(&NAMESPACE_KCL), next.as_bytes()); self.next_id += 1; diff --git a/rust/kcl-lib/src/execution/mod.rs b/rust/kcl-lib/src/execution/mod.rs index 27668612eea..26fce8f3abb 100644 --- a/rust/kcl-lib/src/execution/mod.rs +++ b/rust/kcl-lib/src/execution/mod.rs @@ -180,6 +180,8 @@ pub(crate) mod kcl_value_view; pub(crate) mod machine; mod memory; mod modeling; +#[cfg(all(feature = "remote-execution", not(target_arch = "wasm32")))] +mod remote; mod sketch_solve; mod solver_arc; mod state; diff --git a/rust/kcl-lib/src/execution/remote.rs b/rust/kcl-lib/src/execution/remote.rs new file mode 100644 index 00000000000..def571346be --- /dev/null +++ b/rust/kcl-lib/src/execution/remote.rs @@ -0,0 +1,654 @@ +//! Native clients can execute a project on their existing engine connection. +use std::borrow::Cow; +use std::collections::VecDeque; +use std::path::Path; +use std::path::PathBuf; + +use indexmap::IndexMap; +use kittycad_modeling_cmds::exec_kcl::ExecKclProjectErr; +use kittycad_modeling_cmds::exec_kcl::ExecKclProjectOk; +use kittycad_modeling_cmds::exec_kcl::KclFile; +use kittycad_modeling_cmds::exec_kcl::KclProject; +use kittycad_modeling_cmds::shared::safe_filepath::SafeFilepath; +use kittycad_modeling_cmds::websocket::OkWebSocketResponseData; +use kittycad_modeling_cmds::websocket::WebSocketRequest; + +use super::ExecOutcome; +use super::ExecState; +use super::ExecutorContext; +use super::IdGenerator; +use super::OperationsByModule; +use crate::Program; +use crate::SourceRange; +use crate::TypedPath; +use crate::engine::GridScaleBehavior; +use crate::errors::KclError; +use crate::errors::KclErrorDetails; +use crate::errors::KclErrorWithOutputs; +use crate::modules::ModuleId; +use crate::modules::ModulePath; +use crate::modules::ModuleSource; +use crate::parsing::ast::types::ImportPath; + +impl ExecutorContext { + /// Execute KCL on the engine, retaining this connection for follow-up commands. + /// `state` holds only client-side follow-up settings, not interpreter memory. + pub async fn execute_remote_project( + &self, + program: &Program, + state: &mut ExecState, + ) -> Result { + let mut project = UploadedProject::collect(self, program) + .await + .map_err(KclErrorWithOutputs::no_outputs)?; + let id = uuid::Uuid::new_v4(); + let range = SourceRange::synthetic(); + let request = KclProject::new( + std::mem::take(&mut project.request.files), + project.request.entrypoint.clone(), + ); + let response = self + .engine + .transport + .inner_send_modeling_cmd( + id, + range, + WebSocketRequest::ExecKclProject { + request_id: id, + project: request, + }, + Default::default(), + ) + .await + .map_err(KclErrorWithOutputs::no_outputs)?; + let response = self + .engine + .parse_websocket_response(response, range) + .map_err(KclErrorWithOutputs::no_outputs)?; + let OkWebSocketResponseData::ExecKclProject { result } = response else { + return Err(KclErrorWithOutputs::no_outputs(remote_error( + "Engine did not return an ExecKclProject response; update the engine to support remote KCL execution.", + ))); + }; + let outcome = project.outcome(*result)?; + + state.mod_local.settings = program + .meta_settings() + .map_err(KclErrorWithOutputs::no_outputs)? + .unwrap_or_default(); + state.mod_local.id_generator = IdGenerator::remote_session(); + *self.engine.get_default_planes().write().await = outcome.default_planes.clone(); + // The engine executes without client graphics settings. Reapply them before + // snapshots, including overrides such as highlight_edges=False. + let grid_scale = if self.settings.fixed_size_grid { + GridScaleBehavior::Fixed(Some(state.length_unit())) + } else { + GridScaleBehavior::ScaleWithZoom + }; + self.engine + .reapply_settings( + &self.engine_batch, + &self.settings, + range, + state.id_generator(), + grid_scale, + ) + .await + .map_err(|err| KclErrorWithOutputs::from_error_outcome(err, outcome.clone()))?; + if !self.settings.geometry_only && self.settings.enable_ssao { + self.engine + .send_modeling_cmd( + &self.engine_batch, + state.next_uuid(), + range, + &kittycad_modeling_cmds::ModelingCmd::from( + kittycad_modeling_cmds::SetOrderIndependentTransparency::builder() + .enabled(false) + .build(), + ), + ) + .await + .map_err(|err| KclErrorWithOutputs::from_error_outcome(err, outcome.clone()))?; + } + Ok(outcome) + } +} + +fn remote_error(message: impl Into) -> KclError { + KclError::new_semantic(KclErrorDetails::new(message.into(), vec![SourceRange::synthetic()])) +} + +/// Normalize uploaded virtual paths without accessing the host filesystem. +fn normalize(path: &Path) -> PathBuf { + PathBuf::from( + typed_path::TypedPath::derive(path.as_os_str().as_encoded_bytes()) + .normalize() + .to_string_lossy() + .as_ref(), + ) +} + +struct UploadedProject { + request: KclProject, + /// Upload name -> original client filename and exact bytes sent to the engine. + files: IndexMap)>, + inline: bool, +} + +impl UploadedProject { + async fn collect(ctx: &ExecutorContext, program: &Program) -> Result { + let cwd = std::env::current_dir().map_err(|err| remote_error(err.to_string()))?; + let root = normalize( + &ctx.settings + .current_file + .as_ref() + .map(|path| cwd.join(&path.0)) + .unwrap_or_else(|| { + ctx.settings + .project_directory + .as_ref() + .map(|dir| cwd.join(&dir.0)) + .unwrap_or(cwd) + .join(format!("__zoo_kcl_{}.kcl", uuid::Uuid::new_v4())) + }), + ); + let mut files = IndexMap::new(); + files.insert(root.clone(), program.original_file_contents.as_bytes().to_vec()); + let mut pending = VecDeque::from([(root.clone(), Cow::Borrowed(program), Vec::::new())]); + while let Some((path, program, mut ancestors)) = pending.pop_front() { + if let Ok(real_path) = tokio::fs::canonicalize(&path).await { + if ancestors.contains(&real_path) { + continue; + } + ancestors.push(real_path); + } + let imports = std::cell::RefCell::new(Vec::new()); + crate::walk::walk(&program.ast, |node| { + if let crate::walk::Node::ImportStatement(import) = node { + match &import.path { + ImportPath::Kcl { filename } => imports.borrow_mut().push((filename.clone(), true)), + ImportPath::Foreign { path } => imports.borrow_mut().push((path.clone(), false)), + ImportPath::Std { .. } => {} + } + } + Ok::<_, KclError>(true) + }) + .map_err(|err| remote_error(err.to_string()))?; + for (import, is_kcl) in imports.into_inner() { + reject_absolute_import(&import.to_string())?; + let imported_path = normalize(&path.parent().unwrap_or(Path::new("")).join(&import.0)); + if files.contains_key(&imported_path) { + continue; + } + // Leave missing files and invalid modules to the engine so its + // diagnostics retain the correct source ranges and partial output. + let Ok(bytes) = ctx + .fs + .read(&TypedPath(imported_path.clone()), SourceRange::synthetic()) + .await + else { + continue; + }; + if is_kcl { + if let Ok(source) = std::str::from_utf8(&bytes) + && let Ok(imported) = Program::parse_no_errs(source) + { + pending.push_back((imported_path.clone(), Cow::Owned(imported), ancestors.clone())); + } + } else if imported_path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("gltf") || ext.eq_ignore_ascii_case("glb")) + && !bytes.starts_with(b"glTF") + && let Ok(gltf) = gltf_json::Root::from_slice(&bytes) + { + for buffer in gltf.buffers { + if let Some(uri) = buffer.uri + && !uri.starts_with("data:") + { + reject_absolute_import(&uri)?; + let buffer_path = normalize(&imported_path.parent().unwrap_or(Path::new("")).join(uri)); + if let Ok(data) = ctx + .fs + .read(&TypedPath(buffer_path.clone()), SourceRange::synthetic()) + .await + { + files.entry(buffer_path).or_insert(data); + } + } + } + } + files.insert(imported_path, bytes); + } + } + + // A common root keeps relative parent imports inside the uploaded project. + let mut directory = root.parent().unwrap_or(Path::new("")).to_path_buf(); + for path in files.keys() { + while !path.starts_with(&directory) { + if !directory.pop() { + return Err(remote_error("Imported files do not share a project root")); + } + } + } + let relative = |path: &Path| -> Result { + path.strip_prefix(&directory) + .map(|path| path.to_string_lossy().replace('\\', "/")) + .map_err(|err| remote_error(err.to_string())) + }; + let entrypoint = safe_path(&relative(&root)?)?; + let files: IndexMap<_, _> = files + .into_iter() + .map(|(path, bytes)| Ok((relative(&path)?, (path, bytes)))) + .collect::>()?; + let request = KclProject::new( + files + .iter() + .map(|(name, (_, bytes))| Ok(KclFile::new(safe_path(name)?, bytes.clone()))) + .collect::>()?, + entrypoint, + ); + Ok(Self { + request, + files, + inline: ctx.settings.current_file.is_none(), + }) + } + + fn sources(&self, filenames: &mut IndexMap) -> IndexMap { + let mut sources = IndexMap::new(); + for (id, path) in filenames { + let source = if id.is_top_level() { + let (original, bytes) = &self.files[&self.request.entrypoint.to_string()]; + *path = if self.inline { + ModulePath::Main + } else { + ModulePath::Local { + value: TypedPath(original.clone()), + original_import_path: None, + } + }; + Some(bytes.as_slice()) + } else { + match path { + ModulePath::Main => Some(self.files[&self.request.entrypoint.to_string()].1.as_slice()), + ModulePath::Local { value, .. } => { + let name = normalize(&value.0).to_string_lossy().replace('\\', "/"); + self.files.get(&name).map(|(original, bytes)| { + *value = TypedPath(original.clone()); + bytes.as_slice() + }) + } + ModulePath::Std { value } => crate::modules::read_std(value).map(str::as_bytes), + } + }; + if let Some(source) = source.and_then(|source| std::str::from_utf8(source).ok()) { + sources.insert( + *id, + ModuleSource { + path: path.clone(), + source: source.to_owned(), + }, + ); + } + } + sources + } + + fn outcome(&self, result: Result) -> Result { + let mut response = match result { + Ok(response) => response, + Err(response) => { + if let Some(mut error) = response.error { + self.restore_entrypoint_name(&mut error.details_mut().message); + let mut error = KclErrorWithOutputs::no_outputs(error); + error.non_fatal = response.non_fatal; + for issue in &mut error.non_fatal { + self.restore_entrypoint_name(&mut issue.message); + } + error.variables = response.variables; + error.operations = OperationsByModule { + map: response.operations, + }; + error.artifact_graph = response.artifact_graph; + error.scene_objects = response.scene_objects; + error.filenames = response.filenames; + error.source_files = self.sources(&mut error.filenames); + error.default_planes = response.default_planes.map(|planes| *planes); + return Err(error); + } + // Completed execution with non-fatal errors still returns an + // outcome, as the local interpreter does. + ExecKclProjectOk::builder() + .variables(response.variables) + .operations(response.operations) + .artifact_graph(response.artifact_graph) + .scene_objects(response.scene_objects) + .filenames(response.filenames) + .issues(response.non_fatal) + .maybe_default_planes(response.default_planes) + .build() + } + }; + for issue in &mut response.issues { + self.restore_entrypoint_name(&mut issue.message); + } + let mut filenames = response.filenames; + let source_files = self.sources(&mut filenames); + Ok(ExecOutcome { + variables: response.variables, + operations: OperationsByModule { + map: response.operations, + }, + artifact_graph: response.artifact_graph, + scene_objects: response.scene_objects, + source_range_to_object: Default::default(), + var_solutions: Default::default(), + refactor_metadata: Default::default(), + issues: response.issues, + filenames, + source_files, + default_planes: response.default_planes.map(|planes| *planes), + #[cfg(test)] + test_program_memory: Default::default(), + }) + } + + fn restore_entrypoint_name(&self, message: &mut String) { + let upload_name = self.request.entrypoint.to_string(); + let client_name = if self.inline { + Cow::Borrowed("main") + } else { + self.files[&upload_name].0.to_string_lossy() + }; + *message = message.replace(&format!("`{upload_name}`"), &format!("`{client_name}`")); + } +} + +fn reject_absolute_import(path: &str) -> Result<(), KclError> { + if typed_path::UnixPath::new(path).is_absolute() || typed_path::WindowsPath::new(path).has_root() { + return Err(remote_error(format!( + "Absolute import paths are not supported by engine execution: {path}" + ))); + } + Ok(()) +} + +fn safe_path(path: &str) -> Result { + path.parse() + .map_err(|err| remote_error(format!("Cannot upload project path {path}: {err}"))) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use kittycad_modeling_cmds::websocket::WebSocketResponse; + + use super::*; + use crate::ExecutorSettings; + use crate::engine_connection::EngineManager; + use crate::engine_connection::EngineTransport; + + const ROOT: &str = "@settings(kclVersion = 3.0)\nimport \"sub/main.kcl\"\nvalue = 1\n"; + + async fn project() -> (tempfile::TempDir, UploadedProject) { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("sub")).unwrap(); + std::fs::write( + dir.path().join("sub/main.kcl"), + "@settings(kclVersion = 3.0)\nimport \"main.kcl\"\nexport value = 2\n", + ) + .unwrap(); + std::fs::write(dir.path().join("main.kcl"), ROOT).unwrap(); + std::fs::write(dir.path().join("unrelated.txt"), "must not upload").unwrap(); + let ctx = ExecutorContext::new_mock(Some(ExecutorSettings { + current_file: Some(TypedPath(dir.path().join("main.kcl"))), + ..Default::default() + })) + .await; + let uploaded = UploadedProject::collect(&ctx, &Program::parse_no_errs(ROOT).unwrap()) + .await + .unwrap(); + (dir, uploaded) + } + + fn response() -> ExecKclProjectOk { + ExecKclProjectOk::builder() + .artifact_graph(Default::default()) + .variables(Default::default()) + .operations(Default::default()) + .issues(Vec::new()) + .scene_objects(Vec::new()) + .filenames(IndexMap::from([ + ( + ModuleId::default(), + ModulePath::Local { + value: "main.kcl".into(), + original_import_path: None, + }, + ), + ( + ModuleId::from_usize(1), + ModulePath::Local { + value: "sub/main.kcl".into(), + original_import_path: Some("sub/main.kcl".into()), + }, + ), + ])) + .build() + } + + #[tokio::test] + async fn uploads_only_dependencies_and_stops_at_cycles() { + let (_, project) = project().await; + assert_eq!(project.request.entrypoint.to_string(), "main.kcl"); + assert_eq!( + project.files.keys().map(String::as_str).collect::>(), + ["main.kcl", "sub/main.kcl"] + ); + } + + #[tokio::test] + async fn reconstructs_sources_from_uploaded_bytes_after_files_change() { + let (dir, project) = project().await; + std::fs::write(dir.path().join("main.kcl"), "changed").unwrap(); + let outcome = project.outcome(Ok(response())).unwrap(); + assert_eq!(outcome.source_files[&ModuleId::default()].source, ROOT); + assert_eq!( + outcome.filenames[&ModuleId::default()].to_string(), + dir.path().join("main.kcl").display().to_string() + ); + assert_eq!(outcome.source_files.len(), 2); + } + + #[tokio::test] + async fn diagnostics_keep_the_client_entrypoint_name() { + let ctx = ExecutorContext::new_mock(None).await; + let program = Program::parse_no_errs("@settings(kclVersion = 3.0)\nvalue = 1").unwrap(); + let project = UploadedProject::collect(&ctx, &program).await.unwrap(); + let error = project + .outcome(Err(ExecKclProjectErr::error_before_execution(remote_error(format!( + "The entry point `{}` failed", + project.request.entrypoint + ))))) + .unwrap_err(); + assert_eq!(error.error.message(), "The entry point `main` failed"); + } + + #[tokio::test] + async fn retains_partial_output_on_fatal_errors_and_all_nonfatal_issues() { + let (_, project) = project().await; + let success = response(); + let mut warning = crate::CompilationIssue::err(SourceRange::synthetic(), "keep warning"); + warning.severity = crate::errors::Severity::Warning; + let error = project + .outcome(Err(ExecKclProjectErr::builder() + .error(remote_error("failed")) + .non_fatal(vec![warning.clone()]) + .variables(success.variables) + .operations(success.operations) + .artifact_graph(success.artifact_graph) + .scene_objects(success.scene_objects) + .filenames(success.filenames) + .build())) + .unwrap_err(); + assert_eq!(error.non_fatal, [warning.clone()]); + assert_eq!(error.source_files.len(), 2); + let response = response(); + let outcome = project + .outcome(Err(ExecKclProjectErr::builder() + .non_fatal(vec![warning.clone()]) + .variables(response.variables) + .operations(response.operations) + .artifact_graph(response.artifact_graph) + .scene_objects(response.scene_objects) + .filenames(response.filenames) + .build())) + .unwrap(); + assert_eq!(outcome.issues, [warning]); + } + + #[test] + fn absolute_imports_are_rejected_on_all_platforms() { + for path in ["/tmp/part.kcl", "C:\\project\\part.kcl", "\\\\server\\part.kcl"] { + assert!(reject_absolute_import(path).is_err(), "{path}"); + } + reject_absolute_import("../part.kcl").unwrap(); + } + + #[tokio::test] + async fn includes_gltf_buffers_without_interpreting_them_as_modules() { + let dir = tempfile::tempdir().unwrap(); + let source = "@settings(kclVersion = 3.0)\nimport \"shape.gltf\" as shape\n"; + std::fs::write( + dir.path().join("shape.gltf"), + r#"{"asset":{"version":"2.0"},"buffers":[{"uri":"buffer.bin","byteLength":4}]}"#, + ) + .unwrap(); + std::fs::write(dir.path().join("buffer.bin"), [0, 255, 1, 2]).unwrap(); + let ctx = ExecutorContext::new_mock(Some(ExecutorSettings { + current_file: Some(TypedPath(dir.path().join("main.kcl"))), + ..Default::default() + })) + .await; + let project = UploadedProject::collect(&ctx, &Program::parse_no_errs(source).unwrap()) + .await + .unwrap(); + assert_eq!(project.files["buffer.bin"].1, [0, 255, 1, 2]); + } + + #[tokio::test] + async fn uploaded_nested_entrypoints_resolve_parent_relative_assets() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("sub")).unwrap(); + let code = "@settings(kclVersion = 3.0)\nimport \"../part.step\" as part\n"; + std::fs::write(dir.path().join("part.step"), b"step file sent to the engine").unwrap(); + let client = ExecutorContext::new_mock(Some(ExecutorSettings { + current_file: Some(TypedPath(dir.path().join("sub/main.kcl"))), + ..Default::default() + })) + .await; + let program = Program::parse_no_errs(code).unwrap(); + let project = UploadedProject::collect(&client, &program).await.unwrap().request; + let entrypoint = TypedPath::new(&project.entrypoint.to_string()); + let files = crate::InMemoryFiles::new( + project + .files + .into_iter() + .map(|file| (file.path.to_string(), file.contents)) + .collect(), + ); + // This is the same filesystem and directory setup used by the engine, + // with a mock modeling transport so no external process is required. + let ctx = ExecutorContext::new_with_engine_and_fs( + Arc::new(EngineManager::new_mock()), + crate::new_file_system_handle(files), + ExecutorSettings { + project_directory: entrypoint.parent(), + current_file: Some(entrypoint), + geometry_only: true, + ..Default::default() + }, + ); + let mut state = ExecState::new(&ctx); + ctx.run(&program, &mut state).await.unwrap(); + } + + struct RecordingTransport { + requests: Arc>>, + } + + #[async_trait::async_trait] + impl EngineTransport for RecordingTransport { + async fn inner_fire_modeling_cmd( + &self, + _: uuid::Uuid, + _: SourceRange, + _: WebSocketRequest, + _: std::collections::HashMap, + ) -> Result<(), KclError> { + panic!("unexpected unawaited command") + } + async fn inner_send_modeling_cmd( + &self, + id: uuid::Uuid, + _: SourceRange, + request: WebSocketRequest, + _: std::collections::HashMap, + ) -> Result { + let response = if matches!(request, WebSocketRequest::ExecKclProject { .. }) { + OkWebSocketResponseData::ExecKclProject { + result: Box::new(Ok(response())), + } + } else { + OkWebSocketResponseData::Modeling { + modeling_response: kittycad_modeling_cmds::ok_response::OkModelingCmdResponse::Empty {}, + } + }; + self.requests.lock().await.push(request); + Ok(WebSocketResponse::success(Some(id), response)) + } + async fn close(&self) -> Result<(), crate::engine_connection::TransportCloseError> { + Ok(()) + } + } + + #[tokio::test] + async fn executes_once_and_keeps_the_transport_for_followups() { + let requests = Arc::new(tokio::sync::Mutex::new(Vec::new())); + let mut engine = EngineManager::new_mock(); + engine.transport = Arc::new(Box::new(RecordingTransport { + requests: requests.clone(), + })); + let ctx = ExecutorContext::new_with_engine( + Arc::new(engine), + ExecutorSettings { + geometry_only: true, + ..Default::default() + }, + ); + let mut state = ExecState::new(&ctx); + ctx.execute_remote_project( + &Program::parse_no_errs("@settings(kclVersion = 3.0)\nvalue = 1").unwrap(), + &mut state, + ) + .await + .unwrap(); + ctx.engine + .send_modeling_cmd( + &ctx.engine_batch, + state.next_uuid(), + SourceRange::synthetic(), + &kittycad_modeling_cmds::ModelingCmd::ViewIsometric( + kittycad_modeling_cmds::ViewIsometric::builder().padding(0.1).build(), + ), + ) + .await + .unwrap(); + let requests = requests.lock().await; + assert_eq!(requests.len(), 2); + assert!(matches!(requests[0], WebSocketRequest::ExecKclProject { .. })); + assert!(matches!(requests[1], WebSocketRequest::ModelingCmdReq(_))); + assert_ne!(state.next_uuid(), IdGenerator::new(None).next_uuid()); + } +} diff --git a/rust/kcl-python-bindings/Cargo.toml b/rust/kcl-python-bindings/Cargo.toml index 3c0a2573c31..e140a4d63a4 100644 --- a/rust/kcl-python-bindings/Cargo.toml +++ b/rust/kcl-python-bindings/Cargo.toml @@ -16,6 +16,7 @@ anyhow = { workspace = true } kcl-lib = { path = "../kcl-lib", features = [ "pyo3", "engine", + "remote-execution", "disable-println", ] } #kcl-lib = { path = "../modeling-app/src/wasm-lib/kcl", default-features = false, features = ["pyo3", "engine", "disable-println"] } diff --git a/rust/kcl-python-bindings/src/kcl_session.rs b/rust/kcl-python-bindings/src/kcl_session.rs index 0ce83e5c87e..b8641214377 100644 --- a/rust/kcl-python-bindings/src/kcl_session.rs +++ b/rust/kcl-python-bindings/src/kcl_session.rs @@ -22,6 +22,7 @@ use crate::SnapshotOptions; use crate::bridge::physical_properties::PhysicalPropertiesRequest; use crate::bridge::physical_properties::PhysicalPropertiesResponse; use crate::bridge::sketch_constraints::SketchConstraintReport; +use crate::execute_in_context; use crate::into_miette_for_parse; use crate::into_rich_error; use crate::load_and_parse; @@ -303,29 +304,20 @@ async fn new_kcl_session_impl(input: KclInput, mut params: crate::ContextParams) // Failures here should keep the execution outcome, so that users can still // call sketch report or sketch debug visualization. - let (env_ref, modeling_session_data) = match ctx.run(&program, &mut state).await { + let inner = match execute_in_context(&ctx, &mut state, &program).await { Ok(result) => result, Err(err) => { ctx.close().await; return Err(into_rich_error(err, &filename, &code)); } }; - let api_call_id = modeling_session_data.map(|session| session.api_call_id); + let api_call_id = ctx.engine.get_session_data().await.map(|session| session.api_call_id); let websocket_upgrade_request_id = ctx.engine.websocket_upgrade_request_id().map(str::to_owned); - let outcome = match state.clone().into_exec_outcome(env_ref, &ctx).await { - Ok(inner) => ExecOutcome { - inner: Arc::new(inner), - code: code.into(), - filename: filename.into(), - }, - // This error case only occurs when there's an internal error inside KCL's memory implementation. - // Ideally this would still return a rich error, however, ZK is very unlikely to hit this. - // If we hit it, we should upgrade this. Or make KCL's memory infallible. - Err(err) => { - ctx.close().await; - return Err(to_py_exception(err)); - } + let outcome = ExecOutcome { + inner: Arc::new(inner), + code: code.into(), + filename: filename.into(), }; // Execution succeeded, return the data. diff --git a/rust/kcl-python-bindings/src/lib.rs b/rust/kcl-python-bindings/src/lib.rs index 7beefd3a097..aa0964518a1 100644 --- a/rust/kcl-python-bindings/src/lib.rs +++ b/rust/kcl-python-bindings/src/lib.rs @@ -104,9 +104,11 @@ fn into_rich_error(error: kcl_lib::KclErrorWithOutputs, filename: &str, code: &s } fn render_miette(error: kcl_lib::KclErrorWithOutputs, code: &str) -> String { - let report = error.into_miette_report_with_outputs(code).unwrap(); - let report = miette::Report::new(report); - format!("{report:?}") + let fallback = error.error.to_string(); + match error.into_miette_report_with_outputs(code) { + Ok(report) => format!("{:?}", miette::Report::new(report)), + Err(_) => fallback, + } } fn into_miette_for_parse(filename: &str, input: &str, error: kcl_lib::KclError) -> PyErr { @@ -428,12 +430,29 @@ impl ExecOutcome { struct ExecutedKcl { ctx: ExecutorContext, state: kcl_lib::ExecState, - env_ref: kcl_lib::EnvironmentRef, + outcome: kcl_lib::ExecOutcome, program: kcl_lib::Program, code: String, filename: String, } +async fn execute_in_context( + ctx: &ExecutorContext, + state: &mut ExecState, + program: &Program, +) -> Result { + if ctx.is_mock() { + let (env_ref, _) = ctx.run(program, state).await?; + state + .clone() + .into_exec_outcome(env_ref, ctx) + .await + .map_err(kcl_lib::KclErrorWithOutputs::no_outputs) + } else { + ctx.execute_remote_project(program, state).await + } +} + async fn run_kcl( input: KclInput, mock: bool, @@ -461,7 +480,7 @@ async fn run_kcl( ) .await .map_err(to_py_exception)?; - let (env_ref, _) = match ctx.run(&program, &mut state).await { + let outcome = match execute_in_context(&ctx, &mut state, &program).await { Ok(result) => result, Err(err) => { ctx.close().await; @@ -471,7 +490,7 @@ async fn run_kcl( Ok(ExecutedKcl { ctx, state, - env_ref, + outcome, program, code, filename, @@ -481,19 +500,11 @@ async fn run_kcl( async fn execute_impl(input: KclInput, mock: bool, geometry_only: bool) -> PyResult { let ExecutedKcl { ctx, - state, - env_ref, + outcome, code, filename, .. } = run_kcl(input, mock, None, geometry_only).await?; - let outcome = match state.into_exec_outcome(env_ref, &ctx).await { - Ok(outcome) => outcome, - Err(err) => { - ctx.close().await; - return Err(to_py_exception(err)); - } - }; ctx.close().await; Ok(ExecOutcome { inner: Arc::new(outcome), @@ -539,9 +550,8 @@ async fn sketch_constraint_report_impl(input: KclInput) -> PyResult { - let outcome = state.into_exec_outcome(env_ref, &ctx).await.map_err(to_py_exception)?; + let result = match execute_in_context(&ctx, &mut state, &program).await { + Ok(outcome) => { let mut report: SketchConstraintReport = outcome.sketch_constraint_report().into(); add_execution_issues(&mut report, outcome.issues, |issue| { kcl_lib::render_compilation_issue_miette(&filename, &code, &outcome.source_files, issue) @@ -550,6 +560,7 @@ async fn sketch_constraint_report_impl(input: KclInput) -> PyResult { if err.is_retryable() { + ctx.close().await; return Err(into_rich_error(err, &filename, &code)); } let error_text = render_miette(err.clone(), &code); diff --git a/rust/kcl-python-bindings/tests/tests.py b/rust/kcl-python-bindings/tests/tests.py index db94a75c65d..f0f174a8013 100755 --- a/rust/kcl-python-bindings/tests/tests.py +++ b/rust/kcl-python-bindings/tests/tests.py @@ -51,6 +51,79 @@ "ZOO_API_TOKEN" not in os.environ, reason="requires ZOO_API_TOKEN" ) + +@requires_engine +@pytest.mark.asyncio +@pytest.mark.parametrize("geometry_only", [False, True]) +async def test_engine_execution_preserves_imported_error_sources( + tmp_path, geometry_only +): + dependency = tmp_path / "dependency.kcl" + dependency.write_text( + "@settings(kclVersion = 3.0)\nexport value = missingIdentifier\n" + ) + entrypoint = tmp_path / "main.kcl" + entrypoint.write_text('@settings(kclVersion = 3.0)\nimport "dependency.kcl"\n') + with pytest.raises(kcl.KclError) as caught: + await kcl.execute(str(entrypoint), geometry_only=geometry_only) + assert "missingIdentifier" in str(caught.value) + assert "undefined value" in str(caught.value) + assert str(dependency) in str(caught.value) + + +@requires_engine +@pytest.mark.asyncio +@pytest.mark.parametrize("geometry_only", [False, True]) +async def test_engine_code_can_import_a_file_named_main( + tmp_path, monkeypatch, geometry_only +): + monkeypatch.chdir(tmp_path) + (tmp_path / "main.kcl").write_text( + "@settings(kclVersion = 3.0)\nexport value = 42\n" + ) + outcome = await kcl.execute_code( + '@settings(kclVersion = 3.0)\nimport "main.kcl"\nvalue = main::value\n', + geometry_only=geometry_only, + ) + assert not any(issue.is_err() for issue in outcome.issues()) + + +@requires_engine +@pytest.mark.asyncio +async def test_engine_execution_uploads_parent_relative_assets(tmp_path): + subdirectory = tmp_path / "sub" + subdirectory.mkdir() + entrypoint = subdirectory / "main.kcl" + entrypoint.write_text( + '@settings(kclVersion = 3.0)\nimport "../cube.step" as cube\n' + ) + asset = tmp_path / "cube.step" + with open(cube_step_file, "rb") as source: + asset.write_bytes(source.read()) + async with await kcl.new_kcl_session(str(entrypoint)) as session: + entrypoint.unlink() + asset.unlink() + request = kcl.PhysicalPropertiesRequest() + request.set_volume(kcl.UnitVolume.CubicMillimeters) + result = await session.measure(request) + assert result.get_volume() > 0 + + +@requires_engine +@pytest.mark.asyncio +async def test_engine_execution_rejects_absolute_imports(tmp_path): + dependency = tmp_path / "dependency.kcl" + dependency.write_text("@settings(kclVersion = 2.0)\nexport value = 42\n") + with pytest.raises( + kcl.KclError, + match="Absolute import paths are not supported|import path may not start", + ): + await kcl.execute_code( + f'@settings(kclVersion = 3.0)\nimport "{dependency.as_posix()}"\n', + geometry_only=True, + ) + + MAX_EXECUTION_ATTEMPTS = 3 EXECUTION_RETRY_BASE_DELAY_SECONDS = 1 From 748fd098f14b86373f97afb340a81fe9ee8094e1 Mon Sep 17 00:00:00 2001 From: Adam Chalmers Date: Thu, 22 Oct 2026 17:15:35 -0700 Subject: [PATCH 2/6] Use released engine execution protocol after rebasing on main --- rust/Cargo.lock | 4 ++ rust/Cargo.toml | 2 +- rust/kcl-lib/src/execution/remote.rs | 82 ++++++++++++++++--------- rust/kcl-python-bindings/tests/tests.py | 9 +-- 4 files changed, 62 insertions(+), 35 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 12bea36f99c..3c187c84fad 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -3201,6 +3201,8 @@ dependencies = [ [[package]] name = "kittycad-modeling-cmds-macros" version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c9f1366e47999291754861f2362774f1c98bf478819d3788e70b978381e230" dependencies = [ "kittycad-modeling-cmds-macros-impl", "proc-macro2", @@ -3211,6 +3213,8 @@ dependencies = [ [[package]] name = "kittycad-modeling-cmds-macros-impl" version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "101a9b33a3af1c80223b1e34d8d4a28c7e09b61b43fa8ad643efba35bd2cc10e" dependencies = [ "proc-macro2", "quote", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index e15a8b6261b..9cdb1693f61 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -117,4 +117,4 @@ kcl-error = { path = "kcl-error" } #Example: how to point modeling-app at a different repo (e.g. a branch or a local clone) # [patch.crates-io] # ezpz = { git = "https://github.com/KittyCAD/ezpz.git", branch = "david/circle-circle-tangent" } -kittycad-modeling-cmds = { path = "../../modeling-api/modeling-cmds" } +# kittycad-modeling-cmds = { path = "../../modeling-api/modeling-cmds" } diff --git a/rust/kcl-lib/src/execution/remote.rs b/rust/kcl-lib/src/execution/remote.rs index def571346be..49e50bf3d0f 100644 --- a/rust/kcl-lib/src/execution/remote.rs +++ b/rust/kcl-lib/src/execution/remote.rs @@ -47,6 +47,8 @@ impl ExecutorContext { std::mem::take(&mut project.request.files), project.request.entrypoint.clone(), ); + self.cache.bump_engine_scene_generation().await; + let engine_scene_generation = self.cache.engine_scene_generation().await; let response = self .engine .transport @@ -70,7 +72,7 @@ impl ExecutorContext { "Engine did not return an ExecKclProject response; update the engine to support remote KCL execution.", ))); }; - let outcome = project.outcome(*result)?; + let outcome = project.outcome(*result, engine_scene_generation)?; state.mod_local.settings = program .meta_settings() @@ -294,7 +296,11 @@ impl UploadedProject { sources } - fn outcome(&self, result: Result) -> Result { + fn outcome( + &self, + result: Result, + engine_scene_generation: usize, + ) -> Result { let mut response = match result { Ok(response) => response, Err(response) => { @@ -314,6 +320,7 @@ impl UploadedProject { error.filenames = response.filenames; error.source_files = self.sources(&mut error.filenames); error.default_planes = response.default_planes.map(|planes| *planes); + error.engine_scene_generation = Some(engine_scene_generation); return Err(error); } // Completed execution with non-fatal errors still returns an @@ -346,6 +353,7 @@ impl UploadedProject { refactor_metadata: Default::default(), issues: response.issues, filenames, + engine_scene_generation, source_files, default_planes: response.default_planes.map(|planes| *planes), #[cfg(test)] @@ -452,7 +460,7 @@ mod tests { async fn reconstructs_sources_from_uploaded_bytes_after_files_change() { let (dir, project) = project().await; std::fs::write(dir.path().join("main.kcl"), "changed").unwrap(); - let outcome = project.outcome(Ok(response())).unwrap(); + let outcome = project.outcome(Ok(response()), 1).unwrap(); assert_eq!(outcome.source_files[&ModuleId::default()].source, ROOT); assert_eq!( outcome.filenames[&ModuleId::default()].to_string(), @@ -467,10 +475,13 @@ mod tests { let program = Program::parse_no_errs("@settings(kclVersion = 3.0)\nvalue = 1").unwrap(); let project = UploadedProject::collect(&ctx, &program).await.unwrap(); let error = project - .outcome(Err(ExecKclProjectErr::error_before_execution(remote_error(format!( - "The entry point `{}` failed", - project.request.entrypoint - ))))) + .outcome( + Err(ExecKclProjectErr::error_before_execution(remote_error(format!( + "The entry point `{}` failed", + project.request.entrypoint + )))), + 1, + ) .unwrap_err(); assert_eq!(error.error.message(), "The entry point `main` failed"); } @@ -482,30 +493,38 @@ mod tests { let mut warning = crate::CompilationIssue::err(SourceRange::synthetic(), "keep warning"); warning.severity = crate::errors::Severity::Warning; let error = project - .outcome(Err(ExecKclProjectErr::builder() - .error(remote_error("failed")) - .non_fatal(vec![warning.clone()]) - .variables(success.variables) - .operations(success.operations) - .artifact_graph(success.artifact_graph) - .scene_objects(success.scene_objects) - .filenames(success.filenames) - .build())) + .outcome( + Err(ExecKclProjectErr::builder() + .error(remote_error("failed")) + .non_fatal(vec![warning.clone()]) + .variables(success.variables) + .operations(success.operations) + .artifact_graph(success.artifact_graph) + .scene_objects(success.scene_objects) + .filenames(success.filenames) + .build()), + 1, + ) .unwrap_err(); assert_eq!(error.non_fatal, [warning.clone()]); + assert_eq!(error.engine_scene_generation, Some(1)); assert_eq!(error.source_files.len(), 2); let response = response(); let outcome = project - .outcome(Err(ExecKclProjectErr::builder() - .non_fatal(vec![warning.clone()]) - .variables(response.variables) - .operations(response.operations) - .artifact_graph(response.artifact_graph) - .scene_objects(response.scene_objects) - .filenames(response.filenames) - .build())) + .outcome( + Err(ExecKclProjectErr::builder() + .non_fatal(vec![warning.clone()]) + .variables(response.variables) + .operations(response.operations) + .artifact_graph(response.artifact_graph) + .scene_objects(response.scene_objects) + .filenames(response.filenames) + .build()), + 1, + ) .unwrap(); assert_eq!(outcome.issues, [warning]); + assert_eq!(outcome.engine_scene_generation, 1); } #[test] @@ -628,12 +647,13 @@ mod tests { }, ); let mut state = ExecState::new(&ctx); - ctx.execute_remote_project( - &Program::parse_no_errs("@settings(kclVersion = 3.0)\nvalue = 1").unwrap(), - &mut state, - ) - .await - .unwrap(); + let outcome = ctx + .execute_remote_project( + &Program::parse_no_errs("@settings(kclVersion = 3.0)\nvalue = 1").unwrap(), + &mut state, + ) + .await + .unwrap(); ctx.engine .send_modeling_cmd( &ctx.engine_batch, @@ -645,6 +665,8 @@ mod tests { ) .await .unwrap(); + assert_eq!(outcome.engine_scene_generation, 1); + assert_eq!(ctx.cache.engine_scene_generation().await, 1); let requests = requests.lock().await; assert_eq!(requests.len(), 2); assert!(matches!(requests[0], WebSocketRequest::ExecKclProject { .. })); diff --git a/rust/kcl-python-bindings/tests/tests.py b/rust/kcl-python-bindings/tests/tests.py index f0f174a8013..1b22ceaf8fe 100755 --- a/rust/kcl-python-bindings/tests/tests.py +++ b/rust/kcl-python-bindings/tests/tests.py @@ -1,13 +1,15 @@ #!/usr/bin/env python3 import asyncio import os +import shutil import sys from http.server import BaseHTTPRequestHandler, HTTPServer from threading import Thread from urllib.parse import parse_qs, urlsplit -import kcl import pytest + +import kcl from kcl import Point3d # Get the path to this script's parent directory. @@ -98,8 +100,7 @@ async def test_engine_execution_uploads_parent_relative_assets(tmp_path): '@settings(kclVersion = 3.0)\nimport "../cube.step" as cube\n' ) asset = tmp_path / "cube.step" - with open(cube_step_file, "rb") as source: - asset.write_bytes(source.read()) + await asyncio.to_thread(shutil.copyfile, cube_step_file, asset) async with await kcl.new_kcl_session(str(entrypoint)) as session: entrypoint.unlink() asset.unlink() @@ -113,7 +114,7 @@ async def test_engine_execution_uploads_parent_relative_assets(tmp_path): @pytest.mark.asyncio async def test_engine_execution_rejects_absolute_imports(tmp_path): dependency = tmp_path / "dependency.kcl" - dependency.write_text("@settings(kclVersion = 2.0)\nexport value = 42\n") + dependency.write_text("@settings(kclVersion = 3.0)\nexport value = 42\n") with pytest.raises( kcl.KclError, match="Absolute import paths are not supported|import path may not start", From cefd166ccb00480607a335aaeb4f37c788ba7e30 Mon Sep 17 00:00:00 2001 From: Adam Chalmers Date: Fri, 23 Oct 2026 15:46:16 -0700 Subject: [PATCH 3/6] Remove feature --- rust/kcl-lib/Cargo.toml | 3 +-- rust/kcl-lib/src/engine/engine_manager/ws_transport.rs | 1 - rust/kcl-lib/src/execution/id_generator.rs | 2 +- rust/kcl-lib/src/execution/mod.rs | 2 +- rust/kcl-python-bindings/Cargo.toml | 1 - 5 files changed, 3 insertions(+), 6 deletions(-) diff --git a/rust/kcl-lib/Cargo.toml b/rust/kcl-lib/Cargo.toml index 920fc486636..73cdbd269bc 100644 --- a/rust/kcl-lib/Cargo.toml +++ b/rust/kcl-lib/Cargo.toml @@ -37,7 +37,6 @@ dhat-heap = ["dep:dhat"] # This is used for editor extensions that use the lsp server. disable-println = [] engine = [] -remote-execution = ["engine", "kittycad-modeling-cmds/exec-kcl"] pyo3 = ["dep:pyo3", "dep:pyo3-stub-gen"] # Test-only compiler hooks used by the standalone language-server crate. lsp-test-util = [] @@ -73,7 +72,7 @@ kcl-error = { version = "=0.2.193", path = "../kcl-error" } kcl-syntax = { version = "=0.2.193", path = "../kcl-syntax" } ezpz = { workspace = true } kittycad = { workspace = true } -kittycad-modeling-cmds = { workspace = true } +kittycad-modeling-cmds = { workspace = true, features = ["exec-kcl"] } lazy_static = { workspace = true } libm = "0.2.16" miette = { workspace = true } diff --git a/rust/kcl-lib/src/engine/engine_manager/ws_transport.rs b/rust/kcl-lib/src/engine/engine_manager/ws_transport.rs index d52910be5e0..22df7492ad4 100644 --- a/rust/kcl-lib/src/engine/engine_manager/ws_transport.rs +++ b/rust/kcl-lib/src/engine/engine_manager/ws_transport.rs @@ -381,7 +381,6 @@ impl WebSocketTransport { cmd_id: _, }) ); - #[cfg(feature = "remote-execution")] let binary = binary || matches!(&req, WebSocketRequest::ExecKclProject { .. }); let res = if binary { Self::inner_send_to_engine_binary(req, &mut tcp_write).await diff --git a/rust/kcl-lib/src/execution/id_generator.rs b/rust/kcl-lib/src/execution/id_generator.rs index 0d13f75509f..76e506cec5a 100644 --- a/rust/kcl-lib/src/execution/id_generator.rs +++ b/rust/kcl-lib/src/execution/id_generator.rs @@ -23,7 +23,7 @@ impl IdGenerator { } /// Follow-up commands must not reuse IDs allocated by the remote interpreter. - #[cfg(all(feature = "remote-execution", not(target_arch = "wasm32")))] + #[cfg(not(target_arch = "wasm32"))] pub(super) fn remote_session() -> Self { Self { namespace: Some(uuid::Uuid::new_v4()), diff --git a/rust/kcl-lib/src/execution/mod.rs b/rust/kcl-lib/src/execution/mod.rs index 26fce8f3abb..0e2c0d22939 100644 --- a/rust/kcl-lib/src/execution/mod.rs +++ b/rust/kcl-lib/src/execution/mod.rs @@ -180,7 +180,7 @@ pub(crate) mod kcl_value_view; pub(crate) mod machine; mod memory; mod modeling; -#[cfg(all(feature = "remote-execution", not(target_arch = "wasm32")))] +#[cfg(not(target_arch = "wasm32"))] mod remote; mod sketch_solve; mod solver_arc; diff --git a/rust/kcl-python-bindings/Cargo.toml b/rust/kcl-python-bindings/Cargo.toml index e140a4d63a4..3c0a2573c31 100644 --- a/rust/kcl-python-bindings/Cargo.toml +++ b/rust/kcl-python-bindings/Cargo.toml @@ -16,7 +16,6 @@ anyhow = { workspace = true } kcl-lib = { path = "../kcl-lib", features = [ "pyo3", "engine", - "remote-execution", "disable-println", ] } #kcl-lib = { path = "../modeling-app/src/wasm-lib/kcl", default-features = false, features = ["pyo3", "engine", "disable-println"] } From 804f411b0b795d31effc6076114e2d32dda12065 Mon Sep 17 00:00:00 2001 From: Adam Chalmers Date: Tue, 27 Oct 2026 16:52:29 -0500 Subject: [PATCH 4/6] Review from Jon --- rust/kcl-lib/src/execution/id_generator.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/rust/kcl-lib/src/execution/id_generator.rs b/rust/kcl-lib/src/execution/id_generator.rs index 76e506cec5a..d538f519247 100644 --- a/rust/kcl-lib/src/execution/id_generator.rs +++ b/rust/kcl-lib/src/execution/id_generator.rs @@ -22,11 +22,12 @@ impl IdGenerator { } } - /// Follow-up commands must not reuse IDs allocated by the remote interpreter. + /// A stable client namespace makes follow-up command IDs repeatable without + /// reusing IDs allocated by the remote interpreter. #[cfg(not(target_arch = "wasm32"))] pub(super) fn remote_session() -> Self { Self { - namespace: Some(uuid::Uuid::new_v4()), + namespace: Some(uuid::Uuid::new_v5(&NAMESPACE_KCL, b"remote-client")), ..Self::default() } } From f568b75e1337231ee6ec513cecb4214d8d0a1724 Mon Sep 17 00:00:00 2001 From: Adam Chalmers Date: Tue, 27 Oct 2026 18:24:07 -0500 Subject: [PATCH 5/6] Docs --- rust/kcl-python-bindings/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rust/kcl-python-bindings/src/lib.rs b/rust/kcl-python-bindings/src/lib.rs index aa0964518a1..06c2ecb9322 100644 --- a/rust/kcl-python-bindings/src/lib.rs +++ b/rust/kcl-python-bindings/src/lib.rs @@ -436,6 +436,8 @@ struct ExecutedKcl { filename: String, } +/// If mock execution, execute locally. +/// Otherwise, execute remote on engine, to minimize latency. async fn execute_in_context( ctx: &ExecutorContext, state: &mut ExecState, From 11ca6575b50dc0bf335e6974356141a64e948dcb Mon Sep 17 00:00:00 2001 From: Adam Chalmers Date: Tue, 27 Oct 2026 22:57:33 -0500 Subject: [PATCH 6/6] Neaten up some code --- rust/kcl-lib/src/engine/engine_manager/ws_transport.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rust/kcl-lib/src/engine/engine_manager/ws_transport.rs b/rust/kcl-lib/src/engine/engine_manager/ws_transport.rs index 22df7492ad4..55d04eee7ed 100644 --- a/rust/kcl-lib/src/engine/engine_manager/ws_transport.rs +++ b/rust/kcl-lib/src/engine/engine_manager/ws_transport.rs @@ -374,15 +374,15 @@ impl WebSocketTransport { Some(ToEngineReq { req, request_sent }) => { // Decide whether to send as binary or text, // then send to the engine. - let binary = matches!( + let send_via_binary = matches!( &req, WebSocketRequest::ModelingCmdReq(ModelingCmdReq { cmd: ModelingCmd::ImportFiles { .. }, cmd_id: _, }) + | WebSocketRequest::ExecKclProject { .. } ); - let binary = binary || matches!(&req, WebSocketRequest::ExecKclProject { .. }); - let res = if binary { + let res = if send_via_binary { Self::inner_send_to_engine_binary(req, &mut tcp_write).await } else { Self::inner_send_to_engine(req, &mut tcp_write).await