From ce3e00f1e9ee6d0ff465321a93ddb6a09e641c84 Mon Sep 17 00:00:00 2001 From: aleidel Date: Fri, 9 Jan 2026 16:00:00 +0100 Subject: [PATCH 01/16] first draft of local and remote execution gui --- packages/gui/Cargo.toml | 7 + packages/gui/src/components/files/mod.rs | 5 +- packages/gui/src/components/files/solution.rs | 234 +++++++++++++++++- packages/gui/src/files.rs | 3 +- packages/reana/src/api.rs | 63 ++++- packages/reana/src/reana.rs | 12 +- packages/reana/src/rocrate.rs | 2 +- packages/reana/src/utils.rs | 26 +- packages/remote_execution/Cargo.toml | 2 +- packages/remote_execution/src/lib.rs | 8 + .../src/reana/compatibility.rs | 2 +- .../remote_execution/src/reana/download.rs | 4 +- packages/remote_execution/src/reana/mod.rs | 1 + .../remote_execution/src/reana/rocrate.rs | 4 +- packages/remote_execution/src/reana/status.rs | 8 +- .../remote_execution/src/reana/workflow.rs | 11 +- 16 files changed, 339 insertions(+), 53 deletions(-) diff --git a/packages/gui/Cargo.toml b/packages/gui/Cargo.toml index 53a42f16..905ba2ac 100644 --- a/packages/gui/Cargo.toml +++ b/packages/gui/Cargo.toml @@ -21,6 +21,13 @@ toml.workspace = true commonwl = { path = "../cwl" } repository = { path = "../repository" } s4n_core = { path = "../core" } +remote_execution = { path = "../remote_execution" } +reana = { path = "../reana" } +keyring = { version = "3.6.3", features = [ + "apple-native", + "linux-native", + "windows-native", +] } dioxus = { version = "0.7.2", features = ["router"] } dioxus-free-icons = { version = "0.10.0", features = [ diff --git a/packages/gui/src/components/files/mod.rs b/packages/gui/src/components/files/mod.rs index 20a93125..0a5f18e5 100644 --- a/packages/gui/src/components/files/mod.rs +++ b/packages/gui/src/components/files/mod.rs @@ -99,10 +99,11 @@ mod tests { #[test] fn test_read_node_type() { - let path = "../../testdata/hello_world/workflows/main/main.cwl"; + let base = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let path = format!("{}/../../testdata/hello_world/workflows/main/main.cwl", base); assert_eq!(read_node_type(path), FileType::Workflow); - let path = "../../testdata/hello_world/workflows/calculation/calculation.cwl"; + let path = format!("{}/../../testdata/hello_world/workflows/calculation/calculation.cwl", base); assert_eq!(read_node_type(path), FileType::CommandLineTool); } } diff --git a/packages/gui/src/components/files/solution.rs b/packages/gui/src/components/files/solution.rs index a0309b5d..77bbdf72 100644 --- a/packages/gui/src/components/files/solution.rs +++ b/packages/gui/src/components/files/solution.rs @@ -5,13 +5,18 @@ use crate::layout::{INPUT_TEXT_CLASSES, RELOAD_TRIGGER, Route}; use crate::use_app_state; use dioxus::prelude::*; use dioxus_free_icons::Icon; -use dioxus_free_icons::icons::go_icons::{GoCloud, GoFileDirectory, GoPlusCircle, GoTrash}; +use dioxus_free_icons::icons::go_icons::{GoCloud, GoFileDirectory, GoPlusCircle, GoTrash, GoGear, GoPlay}; use repository::Repository; use repository::submodule::{add_submodule, remove_submodule}; use reqwest::Url; use std::fs; use std::path::{Path, PathBuf}; use std::time::Duration; +use tokio::task; +use anyhow::anyhow; +use commonwl::execution::execute_cwlfile; +use remote_execution::compatibility_adjustments; +use keyring::Entry; #[component] pub fn SolutionView(project_path: ReadSignal, dialog_signals: (Signal, Signal)) -> Element { @@ -30,6 +35,9 @@ pub fn SolutionView(project_path: ReadSignal, dialog_signals: (Signal, dialog_signals: (Signal, dialog_signals: (Signal dir, + None => { + eprintln!("❌ No working directory set"); + return Ok(()); + } + }; + spawn(async move { + execute_reana_workflow(item, working_dir, show_settings).await; + }); + Ok(()) + } + }, + Icon { + width: 10, + height: 10, + icon: GoCloud, + } } + } } } } + for (module , files) in submodule_files() { Submodule_View { module, files, dialog_signals } } + } h2 { class: "mt-2 font-bold flex gap-1 items-center cursor-pointer", @@ -166,6 +232,56 @@ pub fn SolutionView(project_path: ReadSignal, dialog_signals: (Signal, dialog_signals: (Signal< } } } + + +pub fn get_reana_credentials() -> Result, keyring::Error> { + let instance_entry = Entry::new("reana", "instance")?; + let token_entry = Entry::new("reana", "token")?; + + let instance = instance_entry.get_password(); + let token = token_entry.get_password(); + + match (instance, token) { + (Ok(i), Ok(t)) => Ok(Some((i, t))), + _ => Ok(None), + } +} + +pub fn store_reana_credentials(instance: &str, token: &str) -> Result<(), keyring::Error> { + Entry::new("reana", "instance")?.set_password(instance)?; + Entry::new("reana", "token")?.set_password(token)?; + Ok(()) +} + +pub fn normalize_inputs(workflow_json: &mut serde_json::Value, prefix: &str) -> Result<()> { + if let Some(inputs) = workflow_json.get_mut("inputs").and_then(|v| v.as_object_mut()) + && let Some(serde_json::Value::Array(dir_list)) = inputs.get_mut("directories") { + let normalized: Vec = dir_list + .iter() + .filter_map(|v| v.as_str()) + .map(|s| { + let mut path = s.to_string(); + if path.starts_with("../") { + path = path.trim_start_matches("../").to_string(); + } + if path.starts_with(prefix) { + path = path.trim_start_matches(prefix).to_string(); + } + serde_json::Value::String(path) + }) + .collect(); + *dir_list = normalized; + } + Ok(()) +} + +async fn execute_reana_workflow(item: Node, working_dir: PathBuf, mut show_settings: Signal) { + let (instance, token) = match get_reana_credentials() { + Ok(Some(creds)) => creds, + Ok(None) => { + show_settings.set(true); + return; + } + Err(e) => { + eprintln!("❌ Failed to retrieve REANA credentials: {e}"); + return; + } + }; + let input_file = working_dir.join("inputs.yml"); + let cwl_file = item.path.clone(); + let mut workflow = match reana::parser::generate_workflow_json_from_cwl( + &cwl_file, + &Some(input_file), + ) { + Ok(wf) => wf, + Err(e) => { + eprintln!("❌ Failed to generate workflow JSON: {e}"); + return; + } + }; + if let Err(e) = compatibility_adjustments(&mut workflow) { + eprintln!("❌ Compatibility adjustment failed: {e}"); + return; + } + let mut workflow_value = match serde_json::to_value(&workflow) { + Ok(v) => v, + Err(e) => { + eprintln!("❌ Failed to serialize workflow: {e}"); + return; + } + }; + if let Err(e) = normalize_inputs(&mut workflow_value, working_dir.to_str().unwrap_or("")) { + eprintln!("❌ Input normalization failed: {e}"); + return; + } + let workflow = match serde_json::from_value(workflow_value) { + Ok(wf) => wf, + Err(e) => { + eprintln!("❌ Failed to deserialize normalized workflow: {e}"); + return; + } + }; + let workflow_name = std::path::Path::new(&item.name) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(&item.name) + .to_string(); + let reana = reana::reana::Reana::new(instance, token); + let result = task::spawn_blocking(move || { + run_reana_blocking(reana, workflow_name, workflow, working_dir) + }) + .await; + match result { + Ok(Ok(())) => println!("✅ Workflow started successfully"), + Ok(Err(e)) => eprintln!("❌ Workflow failed: {e}"), + Err(e) => eprintln!("❌ Task join error: {e}"), + } +} + +fn run_reana_blocking(reana: reana::reana::Reana, workflow_name: String, workflow_json: serde_json::Value, working_dir: PathBuf) -> anyhow::Result<()> { + reana::api::create_workflow(&reana, &workflow_json, Some(&workflow_name)).map_err(|e| anyhow!("Create workflow failed: {e}"))?; + reana::api::upload_files_from_working_dir(&reana, &working_dir, &workflow_name).map_err(|e| anyhow!("Upload files failed: {e}"))?; + let yaml: serde_yaml::Value = serde_json::from_value(workflow_json).map_err(|e| anyhow!("JSON to YAML conversion failed: {e}"))?; + reana::api::start_workflow(&reana, &workflow_name, None, None, false, &yaml).map_err(|e| anyhow!("Start workflow failed: {e}"))?; + + Ok(()) +} \ No newline at end of file diff --git a/packages/gui/src/files.rs b/packages/gui/src/files.rs index 27ea9357..3195b35e 100644 --- a/packages/gui/src/files.rs +++ b/packages/gui/src/files.rs @@ -42,7 +42,8 @@ mod tests { #[test] pub fn test_get_cwl_files() { - let path = "../../testdata/hello_world"; + let base = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let path = format!("{}/../../testdata/hello_world", base); let files = get_cwl_files(path); assert_eq!(files.len(), 3); } diff --git a/packages/reana/src/api.rs b/packages/reana/src/api.rs index f0d0703b..753518c2 100644 --- a/packages/reana/src/api.rs +++ b/packages/reana/src/api.rs @@ -248,8 +248,49 @@ pub fn download_files(reana: &Reana, workflow_name: &str, files: &[String], fold Ok(()) } + +pub fn upload_files_from_working_dir(reana: &Reana, working_dir: &Path, workflow_name: &str) -> Result<()> { + let mut files: HashSet = HashSet::new(); + collect_files_recursive(working_dir, &mut files).context("Failed to collect files")?; + if files.is_empty() { + eprintln!("No files to upload found in the working directory."); + return Ok(()); + } + for file_name in files { + let mut file_path = PathBuf::from(&file_name); + if !file_path.exists() { + file_path = working_dir.join(&file_path); + } + if !file_path.exists() { + eprintln!("File does not exist: {:?}", file_path); + continue; + } + let mut file = File::open(&file_path).with_context(|| format!("Failed to open file '{}'", file_path.display()))?; + let mut file_content = Vec::new(); + file.read_to_end(&mut file_content) + .with_context(|| format!("Failed to read file '{}'", file_path.display()))?; + // Normalize the path relative to the working directory + let rel_path = file_path.strip_prefix(working_dir).unwrap_or(&file_path).to_path_buf(); + let normalized_name = if rel_path.as_os_str().is_empty() { + file_path.file_name().map(PathBuf::from).unwrap_or_else(|| file_path.clone()) + } else { + rel_path + }; + let mut params = HashMap::new(); + params.insert("file_name".to_string(), sanitize_path(&normalized_name.to_string_lossy())); + let response = reana.post( + &WorkflowEndpoint::Workspace(workflow_name, None), + Content::OctetStream(file_content), + Some(params), + )?; + let _ = response.text().context("Failed to read server response after upload")?; + } + + Ok(()) +} + pub fn get_workflow_workspace(reana_server: &str, reana_token: &str, workflow_id: &str) -> Result { - let response = Reana::new(reana_server, reana_token).get(&WorkflowEndpoint::Workspace(workflow_id, None))?; + let response = Reana::new(reana_server.to_string(), reana_token.to_string()).get(&WorkflowEndpoint::Workspace(workflow_id, None))?; let json_response: Value = response.json().context("❌ Failed to parse JSON response")?; @@ -276,7 +317,7 @@ mod tests { .with_body(r#"{"status": "ok"}"#) .create(); let url = &server.url(); - let reana = Reana::new(url, ""); + let reana = Reana::new(url.to_string(), "".to_string()); let response: Value = ping_reana(&reana).unwrap(); assert_eq!(response["status"], "ok"); @@ -344,7 +385,7 @@ mod tests { let yaml_equiv: serde_yaml::Value = serde_yaml::from_str(&expected_json.to_string()).expect("YAML conversion failed"); let url = &server.base_url(); - let reana = Reana::new(url, "test-token"); + let reana = Reana::new(url.to_string(), "test-token".to_string()); let result = start_workflow(&reana, workflow_id, None, None, false, &yaml_equiv); assert!(result.is_err(), "Expected error, but got Ok."); @@ -420,7 +461,7 @@ mod tests { })); }); let url = &server.base_url(); - let reana = Reana::new(url, "test-token"); + let reana = Reana::new(url.to_string(), "test-token".to_string()); let result = create_workflow(&reana, &workflow_payload, None); assert!(result.is_ok()); @@ -449,7 +490,7 @@ mod tests { }); let url = &server.base_url(); - let reana = Reana::new(url, "invalid-token"); + let reana = Reana::new(url.to_string(), "invalid-token".to_string()); let result = create_workflow(&reana, &workflow_payload, None); assert!(result.is_err()); @@ -472,7 +513,7 @@ mod tests { }); let url = &server.base_url(); - let reana = Reana::new(url, access_token); + let reana = Reana::new(url.to_string(), access_token.to_string()); let result = get_workflow_status(&reana, workflow_id); assert!(result.is_ok()); @@ -497,7 +538,7 @@ mod tests { }); let url = &server.base_url(); - let reana = Reana::new(url, access_token); + let reana = Reana::new(url.to_string(), access_token.to_string()); let result = get_workflow_status(&reana, workflow_id); assert!(result.is_err()); @@ -561,7 +602,7 @@ mod tests { let dummy_cwl = NamedTempFile::new().unwrap(); write(dummy_cwl.path(), "cwlVersion: v1.2").unwrap(); let url = &server.base_url(); - let reana = Reana::new(url, reana_token); + let reana = Reana::new(url.to_string(), reana_token.to_string()); let result = upload_files(&reana, &None, &dummy_cwl.path().to_path_buf(), workflow_name, &workflow_json); assert!(result.is_ok(), "upload_files failed: {:?}", result.err()); @@ -577,7 +618,7 @@ mod tests { let files = vec![]; let url = &server.base_url(); - let reana = Reana::new(url, reana_token); + let reana = Reana::new(url.to_string(), reana_token.to_string()); let result = download_files(&reana, workflow_name, &files, None); assert!(result.is_ok(), "download_files failed: {:?}", result.err()); @@ -609,7 +650,7 @@ mod tests { let files = vec!["results.svg".to_string()]; let url = &server.base_url(); - let reana = Reana::new(url, reana_token); + let reana = Reana::new(url.to_string(), reana_token.to_string()); let result = download_files(&reana, workflow_name, &files, None); env::set_current_dir(&original_dir).expect("Failed to restore original dir"); @@ -641,7 +682,7 @@ mod tests { let files = vec![test_filename.to_string()]; let url = &server.base_url(); - let reana = Reana::new(url, reana_token); + let reana = Reana::new(url.to_string(), reana_token.to_string()); let result = download_files(&reana, workflow_name, &files, None); assert!(result.is_ok(), "download_files failed: {:?}", result.err()); diff --git a/packages/reana/src/reana.rs b/packages/reana/src/reana.rs index ddcc8ff4..141925c7 100644 --- a/packages/reana/src/reana.rs +++ b/packages/reana/src/reana.rs @@ -6,14 +6,14 @@ use reqwest::{ use serde_json::Value; use std::{collections::HashMap, fmt::Display}; -pub struct Reana<'a> { - server: &'a str, - token: &'a str, +pub struct Reana { + server: String, + token: String, } -impl<'a> Reana<'a> { - pub fn new(server: &'a str, token: &'a str) -> Self { - Reana { server, token } +impl Reana { + pub fn new(server: String, token: String) -> Self { + Self { server, token } } fn url(&self, endpoint: &WorkflowEndpoint, params: Option>) -> String { diff --git a/packages/reana/src/rocrate.rs b/packages/reana/src/rocrate.rs index 9d62a2d4..089c63d0 100644 --- a/packages/reana/src/rocrate.rs +++ b/packages/reana/src/rocrate.rs @@ -740,7 +740,7 @@ pub fn create_ro_crate( } let reana_instance = get_or_prompt_credential("reana", "instance", "Enter REANA instance URL: ")?; let reana_token = get_or_prompt_credential("reana", "token", "Enter REANA access token: ")?; - let reana = Reana::new(&reana_instance, &reana_token); + let reana = Reana::new(reana_instance, reana_token); //download intermediate outputs that were found download_files(&reana, workflow_name, &found_paths, Some(&folder_name))?; diff --git a/packages/reana/src/utils.rs b/packages/reana/src/utils.rs index 3910be09..9e9bbd50 100644 --- a/packages/reana/src/utils.rs +++ b/packages/reana/src/utils.rs @@ -106,23 +106,19 @@ pub fn file_matches(requested_file: &str, candidate_path: &str) -> bool { } pub fn collect_files_recursive(dir: &Path, files: &mut HashSet) -> Result<()> { - let entries = fs::read_dir(dir).with_context(|| format!("Failed to read directory: {}", dir.display()))?; - - for entry in entries { - let entry = entry.with_context(|| format!("Failed to read entry in directory: {}", dir.display()))?; - let file_path = entry.path(); - - if file_path.is_dir() { - collect_files_recursive(&file_path, files) - .with_context(|| format!("Failed to collect files recursively from: {}", file_path.display()))?; - } else if file_path.is_file() { - let file_str = file_path - .to_str() - .ok_or_else(|| anyhow::anyhow!("Invalid UTF-8 in file path: {}", file_path.display()))?; - files.insert(file_str.to_string()); + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) && name.starts_with('.') { + continue; + } + + collect_files_recursive(&path, files)?; + } else if let Some(path_str) = path.to_str() { + files.insert(path_str.to_string()); } } - Ok(()) } diff --git a/packages/remote_execution/Cargo.toml b/packages/remote_execution/Cargo.toml index b95690fe..1b47493f 100644 --- a/packages/remote_execution/Cargo.toml +++ b/packages/remote_execution/Cargo.toml @@ -7,7 +7,7 @@ license.workspace = true authors.workspace = true [dependencies] -reana = { path = "../reana" } +reana_ext = { package = "reana", path = "../reana" } commonwl = { path = "../cwl" } util = { path = "../util" } s4n_core = { path = "../core" } diff --git a/packages/remote_execution/src/lib.rs b/packages/remote_execution/src/lib.rs index c60d48b3..579f96d9 100644 --- a/packages/remote_execution/src/lib.rs +++ b/packages/remote_execution/src/lib.rs @@ -1,4 +1,6 @@ use std::path::PathBuf; +use reana_ext::parser::WorkflowJson; +use anyhow::Result; mod reana; pub fn schedule_run(file: &PathBuf, input_file: &Option) -> Result> { @@ -24,3 +26,9 @@ pub fn logout() -> Result<(), Box> { pub fn watch(workflow_name: &str, rocrate: bool) -> Result<(), Box> { reana::watch(workflow_name, rocrate) } + +pub fn compatibility_adjustments( + workflow_json: &mut WorkflowJson, +) -> Result<()> { + crate::reana::compatibility_adjustments(workflow_json) +} diff --git a/packages/remote_execution/src/reana/compatibility.rs b/packages/remote_execution/src/reana/compatibility.rs index ee56719d..8ba7e6b8 100644 --- a/packages/remote_execution/src/reana/compatibility.rs +++ b/packages/remote_execution/src/reana/compatibility.rs @@ -1,7 +1,7 @@ use colored::Colorize; use commonwl::{prelude::*, requirements::WorkDirItem}; use log::{info, warn}; -use reana::parser::WorkflowJson; +use reana_ext::parser::WorkflowJson; use std::collections::HashMap; use std::process::{Command as SystemCommand, Stdio}; use std::{env, fs, path::Path}; diff --git a/packages/remote_execution/src/reana/download.rs b/packages/remote_execution/src/reana/download.rs index b6e9a7d4..ed6d5bce 100644 --- a/packages/remote_execution/src/reana/download.rs +++ b/packages/remote_execution/src/reana/download.rs @@ -1,5 +1,5 @@ use crate::reana::{auth::login_reana, workflow::analyze_workflow_logs}; -use reana::{ +use reana_ext::{ api::{download_files, get_workflow_specification, get_workflow_status}, reana::Reana, }; @@ -7,7 +7,7 @@ use std::error::Error; pub fn download_remote_results(workflow_name: &str, output_dir: Option<&String>) -> Result<(), Box> { let (reana_instance, reana_token) = login_reana()?; - let reana = Reana::new(&reana_instance, &reana_token); + let reana = Reana::new(reana_instance, reana_token); let status_response = get_workflow_status(&reana, workflow_name).map_err(|e| format!("Failed to fetch workflow status: {e}"))?; let workflow_status = status_response["status"].as_str().unwrap_or("unknown"); diff --git a/packages/remote_execution/src/reana/mod.rs b/packages/remote_execution/src/reana/mod.rs index 2cd05b86..2625be7c 100644 --- a/packages/remote_execution/src/reana/mod.rs +++ b/packages/remote_execution/src/reana/mod.rs @@ -11,3 +11,4 @@ pub use rocrate::export_rocrate; pub use status::check_remote_status; pub use status::watch; pub use workflow::execute_remote_start; +pub use compatibility::compatibility_adjustments; diff --git a/packages/remote_execution/src/reana/rocrate.rs b/packages/remote_execution/src/reana/rocrate.rs index 6a95f90b..67f2633d 100644 --- a/packages/remote_execution/src/reana/rocrate.rs +++ b/packages/remote_execution/src/reana/rocrate.rs @@ -1,5 +1,5 @@ use crate::reana::{auth::login_reana, workflow::analyze_workflow_logs}; -use reana::{ +use reana_ext::{ api::{get_workflow_logs, get_workflow_specification, get_workflow_status, get_workflow_workspace}, reana::Reana, rocrate::create_ro_crate, @@ -8,7 +8,7 @@ use std::{error::Error, fs, path::PathBuf}; pub fn export_rocrate(workflow_name: &str, ro_crate_dir: Option<&String>) -> Result<(), Box> { let (reana_instance, reana_token) = login_reana()?; - let reana = Reana::new(&reana_instance, &reana_token); + let reana = Reana::new(reana_instance.clone(), reana_token.clone()); // Get workflow status, only export if finished? let status_response = get_workflow_logs(&reana, workflow_name).map_err(|e| format!("Failed to fetch workflow status: {e}"))?; diff --git a/packages/remote_execution/src/reana/status.rs b/packages/remote_execution/src/reana/status.rs index f0d1b4d8..70ff9c02 100644 --- a/packages/remote_execution/src/reana/status.rs +++ b/packages/remote_execution/src/reana/status.rs @@ -3,7 +3,7 @@ use crate::reana::{ export_rocrate, workflow::{analyze_workflow_logs, get_saved_workflows}, }; -use reana::{api::get_workflow_status, reana::Reana}; +use reana_ext::{api::get_workflow_status, reana::Reana}; use std::{error::Error, path::PathBuf, thread, time::Duration}; pub(super) fn status_file_path() -> PathBuf { std::env::temp_dir().join("workflow_status_list.json") @@ -11,7 +11,7 @@ pub(super) fn status_file_path() -> PathBuf { pub fn check_remote_status(workflow_name: &Option) -> Result<(), Box> { let (reana_instance, reana_token) = login_reana()?; - let reana = Reana::new(&reana_instance, &reana_token); + let reana = Reana::new(reana_instance.clone(), reana_token); if let Some(name) = workflow_name { evaluate_workflow_status(&reana, name, true)?; @@ -51,13 +51,13 @@ fn evaluate_workflow_status(reana: &Reana, name: &str, analyze_logs: bool) -> Re pub fn watch(workflow_name: &str, rocrate: bool) -> Result<(), Box> { let (reana_instance, reana_token) = login_reana()?; - let reana = Reana::new(&reana_instance, &reana_token); + let reana = Reana::new(reana_instance, reana_token); const POLL_INTERVAL_SECS: u64 = 5; const TERMINAL_STATUSES: [&str; 3] = ["finished", "failed", "deleted"]; loop { - let status_response = reana::api::get_workflow_status(&reana, workflow_name).map_err(|e| format!("Failed to fetch workflow status: {e}"))?; + let status_response = get_workflow_status(&reana, workflow_name).map_err(|e| format!("Failed to fetch workflow status: {e}"))?; let workflow_status = status_response["status"].as_str().unwrap_or("unknown"); if TERMINAL_STATUSES.contains(&workflow_status) { match workflow_status { diff --git a/packages/remote_execution/src/reana/workflow.rs b/packages/remote_execution/src/reana/workflow.rs index d5d2ddd6..0524d848 100644 --- a/packages/remote_execution/src/reana/workflow.rs +++ b/packages/remote_execution/src/reana/workflow.rs @@ -1,6 +1,6 @@ use crate::reana::{auth::login_reana, compatibility::compatibility_adjustments, status::status_file_path}; -use reana::{ - api::{create_workflow, ping_reana}, +use reana_ext::{ + api::{create_workflow, ping_reana, upload_files, start_workflow}, parser::generate_workflow_json_from_cwl, reana::Reana, }; @@ -18,7 +18,8 @@ pub fn execute_remote_start(file: &PathBuf, input_file: &Option) -> Res // Get credentials let (reana_instance, reana_token) = login_reana()?; - let reana = Reana::new(&reana_instance, &reana_token); + let reana = Reana::new(reana_instance.clone(), reana_token.clone()); + // Ping let ping_status = ping_reana(&reana)?; @@ -36,8 +37,8 @@ pub fn execute_remote_start(file: &PathBuf, input_file: &Option) -> Res let Some(workflow_name) = create_response["workflow_name"].as_str() else { return Err("Missing workflow_name in response".into()); }; - reana::api::upload_files(&reana, input_file, file, workflow_name, &workflow_json)?; - reana::api::start_workflow(&reana, workflow_name, None, None, false, &converted_yaml)?; + upload_files(&reana, input_file, file, workflow_name, &workflow_json)?; + start_workflow(&reana, workflow_name, None, None, false, &converted_yaml)?; eprintln!("✅ Started workflow execution"); save_workflow_name(&reana_instance, workflow_name)?; From d692fa8694990273c5edfc01b1247243d7157f9c Mon Sep 17 00:00:00 2001 From: aleidel Date: Fri, 30 Jan 2026 16:43:47 +0100 Subject: [PATCH 02/16] speed up compatability docker push and changes gui execution --- packages/cli/src/commands/execute.rs | 4 +- packages/gui/src/components/files/solution.rs | 119 +---------- packages/gui/src/lib.rs | 1 + packages/gui/src/reana_integration.rs | 121 ++++++++++++ packages/reana/src/api.rs | 186 +++++++----------- packages/remote_execution/src/lib.rs | 4 +- .../src/reana/compatibility.rs | 43 +++- .../remote_execution/src/reana/workflow.rs | 7 +- 8 files changed, 234 insertions(+), 251 deletions(-) create mode 100644 packages/gui/src/reana_integration.rs diff --git a/packages/cli/src/commands/execute.rs b/packages/cli/src/commands/execute.rs index 52dd2aef..8d5a763d 100644 --- a/packages/cli/src/commands/execute.rs +++ b/packages/cli/src/commands/execute.rs @@ -4,7 +4,7 @@ use commonwl::execution::{ContainerEngine, execute_cwlfile, set_container_engine use commonwl::prelude::*; use remote_execution::{check_status, download_results, export_rocrate, logout}; use serde_yaml::{Number, Value}; -use std::{collections::HashMap, error::Error, fs, path::PathBuf}; +use std::{collections::HashMap, error::Error, fs, path::{Path, PathBuf}}; pub fn handle_execute_commands(subcommand: &ExecuteCommands) -> Result<(), Box> { match subcommand { @@ -128,7 +128,7 @@ pub fn execute_local(args: &LocalExecuteArgs) -> Result<(), ExecutionError> { execute_cwlfile(&args.file, &args.args, args.out_dir.clone()) } -pub fn schedule_run(file: &PathBuf, input_file: &Option, rocrate: bool, watch: bool, logout: bool) -> Result<(), Box> { +pub fn schedule_run(file: &Path, input_file: &Option, rocrate: bool, watch: bool, logout: bool) -> Result<(), Box> { let workflow_name = remote_execution::schedule_run(file, input_file)?; if watch { diff --git a/packages/gui/src/components/files/solution.rs b/packages/gui/src/components/files/solution.rs index 77bbdf72..5fcffec1 100644 --- a/packages/gui/src/components/files/solution.rs +++ b/packages/gui/src/components/files/solution.rs @@ -3,6 +3,7 @@ use crate::components::{ICON_SIZE, SmallRoundActionButton}; use crate::files::{get_cwl_files, get_submodules_cwl_files}; use crate::layout::{INPUT_TEXT_CLASSES, RELOAD_TRIGGER, Route}; use crate::use_app_state; +use crate::reana_integration::{execute_reana_workflow, store_reana_credentials}; use dioxus::prelude::*; use dioxus_free_icons::Icon; use dioxus_free_icons::icons::go_icons::{GoCloud, GoFileDirectory, GoPlusCircle, GoTrash, GoGear, GoPlay}; @@ -12,11 +13,7 @@ use reqwest::Url; use std::fs; use std::path::{Path, PathBuf}; use std::time::Duration; -use tokio::task; -use anyhow::anyhow; use commonwl::execution::execute_cwlfile; -use remote_execution::compatibility_adjustments; -use keyring::Entry; #[component] pub fn SolutionView(project_path: ReadSignal, dialog_signals: (Signal, Signal)) -> Element { @@ -368,118 +365,4 @@ pub fn Submodule_View(module: String, files: Vec, dialog_signals: (Signal< } } } -} - - -pub fn get_reana_credentials() -> Result, keyring::Error> { - let instance_entry = Entry::new("reana", "instance")?; - let token_entry = Entry::new("reana", "token")?; - - let instance = instance_entry.get_password(); - let token = token_entry.get_password(); - - match (instance, token) { - (Ok(i), Ok(t)) => Ok(Some((i, t))), - _ => Ok(None), - } -} - -pub fn store_reana_credentials(instance: &str, token: &str) -> Result<(), keyring::Error> { - Entry::new("reana", "instance")?.set_password(instance)?; - Entry::new("reana", "token")?.set_password(token)?; - Ok(()) -} - -pub fn normalize_inputs(workflow_json: &mut serde_json::Value, prefix: &str) -> Result<()> { - if let Some(inputs) = workflow_json.get_mut("inputs").and_then(|v| v.as_object_mut()) - && let Some(serde_json::Value::Array(dir_list)) = inputs.get_mut("directories") { - let normalized: Vec = dir_list - .iter() - .filter_map(|v| v.as_str()) - .map(|s| { - let mut path = s.to_string(); - if path.starts_with("../") { - path = path.trim_start_matches("../").to_string(); - } - if path.starts_with(prefix) { - path = path.trim_start_matches(prefix).to_string(); - } - serde_json::Value::String(path) - }) - .collect(); - *dir_list = normalized; - } - Ok(()) -} - -async fn execute_reana_workflow(item: Node, working_dir: PathBuf, mut show_settings: Signal) { - let (instance, token) = match get_reana_credentials() { - Ok(Some(creds)) => creds, - Ok(None) => { - show_settings.set(true); - return; - } - Err(e) => { - eprintln!("❌ Failed to retrieve REANA credentials: {e}"); - return; - } - }; - let input_file = working_dir.join("inputs.yml"); - let cwl_file = item.path.clone(); - let mut workflow = match reana::parser::generate_workflow_json_from_cwl( - &cwl_file, - &Some(input_file), - ) { - Ok(wf) => wf, - Err(e) => { - eprintln!("❌ Failed to generate workflow JSON: {e}"); - return; - } - }; - if let Err(e) = compatibility_adjustments(&mut workflow) { - eprintln!("❌ Compatibility adjustment failed: {e}"); - return; - } - let mut workflow_value = match serde_json::to_value(&workflow) { - Ok(v) => v, - Err(e) => { - eprintln!("❌ Failed to serialize workflow: {e}"); - return; - } - }; - if let Err(e) = normalize_inputs(&mut workflow_value, working_dir.to_str().unwrap_or("")) { - eprintln!("❌ Input normalization failed: {e}"); - return; - } - let workflow = match serde_json::from_value(workflow_value) { - Ok(wf) => wf, - Err(e) => { - eprintln!("❌ Failed to deserialize normalized workflow: {e}"); - return; - } - }; - let workflow_name = std::path::Path::new(&item.name) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or(&item.name) - .to_string(); - let reana = reana::reana::Reana::new(instance, token); - let result = task::spawn_blocking(move || { - run_reana_blocking(reana, workflow_name, workflow, working_dir) - }) - .await; - match result { - Ok(Ok(())) => println!("✅ Workflow started successfully"), - Ok(Err(e)) => eprintln!("❌ Workflow failed: {e}"), - Err(e) => eprintln!("❌ Task join error: {e}"), - } -} - -fn run_reana_blocking(reana: reana::reana::Reana, workflow_name: String, workflow_json: serde_json::Value, working_dir: PathBuf) -> anyhow::Result<()> { - reana::api::create_workflow(&reana, &workflow_json, Some(&workflow_name)).map_err(|e| anyhow!("Create workflow failed: {e}"))?; - reana::api::upload_files_from_working_dir(&reana, &working_dir, &workflow_name).map_err(|e| anyhow!("Upload files failed: {e}"))?; - let yaml: serde_yaml::Value = serde_json::from_value(workflow_json).map_err(|e| anyhow!("JSON to YAML conversion failed: {e}"))?; - reana::api::start_workflow(&reana, &workflow_name, None, None, false, &yaml).map_err(|e| anyhow!("Start workflow failed: {e}"))?; - - Ok(()) } \ No newline at end of file diff --git a/packages/gui/src/lib.rs b/packages/gui/src/lib.rs index 874474a0..98cc19c2 100644 --- a/packages/gui/src/lib.rs +++ b/packages/gui/src/lib.rs @@ -21,6 +21,7 @@ pub mod graph; pub mod layout; pub mod types; pub mod workflow; +pub mod reana_integration; #[derive(Default, Clone, Debug, Serialize, Deserialize)] pub struct ApplicationState { diff --git a/packages/gui/src/reana_integration.rs b/packages/gui/src/reana_integration.rs new file mode 100644 index 00000000..9a7ac47b --- /dev/null +++ b/packages/gui/src/reana_integration.rs @@ -0,0 +1,121 @@ +use anyhow::anyhow; +use keyring::Entry; +use std::path::PathBuf; +use tokio::task; +use crate::components::files::Node; +use remote_execution::compatibility_adjustments; + use dioxus::prelude::WritableExt; + +pub fn get_reana_credentials() -> Result, keyring::Error> { + let instance_entry = Entry::new("reana", "instance")?; + let token_entry = Entry::new("reana", "token")?; + + let instance = instance_entry.get_password(); + let token = token_entry.get_password(); + + match (instance, token) { + (Ok(i), Ok(t)) => Ok(Some((i, t))), + _ => Ok(None), + } +} + +pub fn store_reana_credentials(instance: &str, token: &str) -> Result<(), keyring::Error> { + Entry::new("reana", "instance")?.set_password(instance)?; + Entry::new("reana", "token")?.set_password(token)?; + Ok(()) +} + +pub fn normalize_inputs(workflow_json: &mut serde_json::Value, prefix: &str) -> anyhow::Result<()> { + if let Some(inputs) = workflow_json.get_mut("inputs").and_then(|v| v.as_object_mut()) + && let Some(serde_json::Value::Array(dir_list)) = inputs.get_mut("directories") + { + let normalized: Vec = dir_list + .iter() + .filter_map(|v| v.as_str()) + .map(|s| { + let mut path = s.to_string(); + if path.starts_with("../") { + path = path.trim_start_matches("../").to_string(); + } + if path.starts_with(prefix) { + path = path.trim_start_matches(prefix).to_string(); + } + serde_json::Value::String(path) + }) + .collect(); + *dir_list = normalized; + } + Ok(()) +} + +pub async fn execute_reana_workflow( + item: Node, + working_dir: PathBuf, + mut show_settings: dioxus::prelude::Signal, +) { + let (instance, token) = match get_reana_credentials() { + Ok(Some(creds)) => creds, + Ok(None) => { + show_settings.set(true); + return; + } + Err(e) => { + eprintln!("❌ Failed to retrieve REANA credentials: {e}"); + return; + } + }; + let input_file = working_dir.join("inputs.yml"); + let cwl_file = item.path.clone(); + let mut workflow = match reana::parser::generate_workflow_json_from_cwl(&cwl_file, &Some(input_file)) { + Ok(wf) => wf, + Err(e) => { + eprintln!("❌ Failed to generate workflow JSON: {e}"); + return; + } + }; + if let Err(e) = compatibility_adjustments(&mut workflow) { + eprintln!("❌ Compatibility adjustment failed: {e}"); + return; + } + let mut workflow_value = match serde_json::to_value(&workflow) { + Ok(v) => v, + Err(e) => { + eprintln!("❌ Failed to serialize workflow: {e}"); + return; + } + }; + if let Err(e) = normalize_inputs(&mut workflow_value, working_dir.to_str().unwrap_or("")) { + eprintln!("❌ Input normalization failed: {e}"); + return; + } + let workflow = match serde_json::from_value(workflow_value) { + Ok(wf) => wf, + Err(e) => { + eprintln!("❌ Failed to deserialize normalized workflow: {e}"); + return; + } + }; + let workflow_name = std::path::Path::new(&item.name) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(&item.name) + .to_string(); + let reana = reana::reana::Reana::new(instance, token); + let result = task::spawn_blocking(move || { + run_reana_blocking(reana, workflow_name, workflow, working_dir, item.path) + }) + .await; + match result { + Ok(Ok(())) => println!("✅ Workflow started successfully"), + Ok(Err(e)) => eprintln!("❌ Workflow failed: {e}"), + Err(e) => eprintln!("❌ Task join error: {e}"), + } +} + +fn run_reana_blocking(reana: reana::reana::Reana, workflow_name: String, workflow_json: serde_json::Value, working_dir: PathBuf, file_name: PathBuf) -> anyhow::Result<()> { + reana::api::create_workflow(&reana, &workflow_json, Some(&workflow_name)).map_err(|e| anyhow!("Create workflow failed: {e}"))?; + reana::api::upload_files(&reana, &None, &file_name, &workflow_name, &workflow_json, Some(&working_dir)).map_err(|e| anyhow!("Upload files failed: {e}"))?; + let yaml: serde_yaml::Value = serde_json::from_value(workflow_json).map_err(|e| anyhow!("JSON to YAML conversion failed: {e}"))?; + reana::api::start_workflow(&reana, &workflow_name, None, None, false, &yaml).map_err(|e| anyhow!("Start workflow failed: {e}"))?; + Ok(()) +} \ No newline at end of file diff --git a/packages/reana/src/api.rs b/packages/reana/src/api.rs index 753518c2..db294ffa 100644 --- a/packages/reana/src/api.rs +++ b/packages/reana/src/api.rs @@ -1,5 +1,5 @@ use crate::reana::{Content, Reana, WorkflowEndpoint}; -use crate::utils::{collect_files_recursive, get_location, load_cwl_yaml, load_yaml_file, resolve_input_file_path, sanitize_path}; +use crate::utils::{collect_files_recursive, load_cwl_yaml, load_yaml_file, resolve_input_file_path, sanitize_path, get_location}; use anyhow::{Context, Result}; use serde_json::Value; use serde_json::json; @@ -87,123 +87,112 @@ pub fn get_workflow_specification(reana: &Reana, workflow_id: &str) -> Result, file: &PathBuf, workflow_name: &str, workflow_json: &Value) -> Result<()> { - eprintln!("Uploading Files ..."); +pub fn upload_files(reana: &Reana, input_yaml: &Option, file: &Path, workflow_name: &str, workflow_json: &Value, working_dir: Option<&PathBuf>) -> Result<()> { + eprintln!("📤 Uploading Files ..."); let mut files: HashSet = HashSet::new(); let input_yaml_value = if let Some(input_path) = input_yaml { Some(load_yaml_file(Path::new(input_path)).context("Failed to load input YAML file")?) } else { None }; - let base_path = std::env::current_dir() .context("Failed to get current working directory")? .to_string_lossy() .to_string(); - let cwl_yaml = load_cwl_yaml(&base_path, file).context("Failed to load CWL YAML")?; - - // Collect files from workflow JSON if let Some(inputs) = workflow_json.get("inputs") { if let Some(Value::Array(file_list)) = inputs.get("files") { for f in file_list.iter().filter_map(|v| v.as_str()) { files.insert(f.to_string()); } } - if let Some(Value::Array(dir_list)) = inputs.get("directories") { + if let Some(Value::Array(dir_list)) = inputs.get("directories").or_else(|| inputs.get("directory")){ for dir in dir_list.iter().filter_map(|v| v.as_str()) { - let path = Path::new(dir); - if path.exists() && path.is_dir() { - for entry in fs::read_dir(path).context("Failed to read directory")? { - let entry = entry.context("Failed to read directory entry")?; - let file_path = entry.path(); - if file_path.is_dir() { - collect_files_recursive(&file_path, &mut files).context("Failed to recursively collect directory files")?; - } else if file_path.is_file() - && let Some(file_str) = file_path.to_str() - { - files.insert(file_str.to_string()); - } - } - } else { - // Resolve indirect directories - if let Ok(Some(resolved_path)) = - resolve_input_file_path(path.to_string_lossy().as_ref(), input_yaml_value.as_ref(), Some(&cwl_yaml)) - { - let cwd = std::env::current_dir().context("Failed to get cwd")?; - - let base_path = if let Some(input_yaml_str) = input_yaml { - cwd.join(input_yaml_str) - } else { - cwd.join(file) - }; - - let path_str = base_path.to_string_lossy().to_string(); - - let l = get_location(&path_str, Path::new(&resolved_path)).context("Failed to get location")?; - let resolved_dir = PathBuf::from(l); - - if resolved_dir.exists() && resolved_dir.is_dir() { - for entry in fs::read_dir(resolved_dir).context("Failed to read resolved directory")? { - let entry = entry.context("Failed to read directory entry")?; - let file_path = entry.path(); - if file_path.is_dir() { - collect_files_recursive(&file_path, &mut files).context("Failed to collect files recursively")?; - } else if file_path.is_file() { - let relative = file_path.strip_prefix(&cwd).unwrap_or(&file_path); - if let Some(file_str) = relative.to_str() { - files.insert(file_str.to_string()); - } - } + let mut path = PathBuf::from(dir); + if !path.exists() { + if let Some(base) = working_dir { + let candidate = base.join(&path); + if candidate.exists() { + path = candidate; + } + else if let Ok(Some(resolved_path)) = resolve_input_file_path(path.to_string_lossy().as_ref(), input_yaml_value.as_ref(), Some(&cwl_yaml)) { + let working_str = base.to_string_lossy().to_string(); + if let Ok(loc) = get_location(&working_str, Path::new(&resolved_path)) + { + path = PathBuf::from(loc); + } else { + eprintln!("⚠️ Could not map location for {:?}", resolved_path); + continue; } + } else { + eprintln!("⚠️ Directory not found: {:?}", dir); + continue; } + } else { + eprintln!("⚠️ Directory not found: {:?} (no working_dir provided)", dir); + continue; } } + if path.is_dir() { + collect_files_recursive(&path, &mut files) + .context("Failed to collect files recursively")?; + } } } } if files.is_empty() { - eprintln!("No files to upload found in workflow JSON."); + eprintln!("⚠️ No files to upload found in workflow JSON."); return Ok(()); } - for file_name in files { let mut file_path = PathBuf::from(&file_name); - if !file_path.exists() - && let Ok(Some(resolved)) = resolve_input_file_path(file_path.to_string_lossy().as_ref(), input_yaml_value.as_ref(), Some(&cwl_yaml)) - { - let cwd = std::env::current_dir().context("Failed to get cwd")?; - let base_path = if let Some(input_yaml_str) = input_yaml { - cwd.join(input_yaml_str) + if !file_path.exists() { + let mut resolved = None; + if let Some(base) = working_dir { + let candidate = base.join(&file_path); + if candidate.exists() { + resolved = Some(candidate); + } + } + if resolved.is_none() + && let Ok(Some(resolved_path)) = resolve_input_file_path(file_path.to_string_lossy().as_ref(), input_yaml_value.as_ref(), Some(&cwl_yaml)) + && let Some(base) = working_dir + { + let working_str = base.to_string_lossy().to_string(); + if let Ok(loc) = get_location(&working_str, Path::new(&resolved_path)) { + resolved = Some(PathBuf::from(loc)); + } + } + if let Some(found) = resolved { + file_path = found; } else { - cwd.join(file) - }; - - let path_str = base_path.to_string_lossy().to_string(); - let l = get_location(&path_str, Path::new(&resolved)).context("Failed to resolve file location")?; - file_path = PathBuf::from(l); + eprintln!("⚠️ File not found: {:?}", file_path); + continue; + } } - - // Read file content - let mut file = fs::File::open(&file_path).with_context(|| format!("Failed to open file '{}'", file_path.display()))?; + let mut handle = fs::File::open(&file_path).with_context(|| format!("Failed to open file '{}'", file_path.display()))?; let mut file_content = Vec::new(); - file.read_to_end(&mut file_content) - .with_context(|| format!("Failed to read file '{}'", file_path.display()))?; - - let name = pathdiff::diff_paths(&file_name, std::env::current_dir()?).unwrap_or_else(|| Path::new(&file_name).to_path_buf()); - + handle.read_to_end(&mut file_content).with_context(|| format!("Failed to read file '{}'", file_path.display()))?; + let relative = if let Some(base) = working_dir { + pathdiff::diff_paths(&file_path, base).unwrap_or_else(|| file_path.clone()) + } else { + file_path.clone() + }; let mut params = HashMap::new(); - params.insert("file_name".to_string(), sanitize_path(&name.to_string_lossy())); + params.insert("file_name".to_string(), sanitize_path(&relative.to_string_lossy())); let response = reana.post( &WorkflowEndpoint::Workspace(workflow_name, None), Content::OctetStream(file_content), Some(params), )?; - eprintln!("✔️ Uploaded {file_name}"); - let _response_text = response.text().context("Failed to read server response after upload")?; + if response.status().is_success() { + eprintln!("✔️ Uploaded {file_name}"); + } else { + let error_text = response.text().unwrap_or_else(|_| "Unknown error".to_string()); + eprintln!("❌ Failed to upload {file_name}. Response: {error_text}"); + } } - Ok(()) } @@ -248,47 +237,6 @@ pub fn download_files(reana: &Reana, workflow_name: &str, files: &[String], fold Ok(()) } - -pub fn upload_files_from_working_dir(reana: &Reana, working_dir: &Path, workflow_name: &str) -> Result<()> { - let mut files: HashSet = HashSet::new(); - collect_files_recursive(working_dir, &mut files).context("Failed to collect files")?; - if files.is_empty() { - eprintln!("No files to upload found in the working directory."); - return Ok(()); - } - for file_name in files { - let mut file_path = PathBuf::from(&file_name); - if !file_path.exists() { - file_path = working_dir.join(&file_path); - } - if !file_path.exists() { - eprintln!("File does not exist: {:?}", file_path); - continue; - } - let mut file = File::open(&file_path).with_context(|| format!("Failed to open file '{}'", file_path.display()))?; - let mut file_content = Vec::new(); - file.read_to_end(&mut file_content) - .with_context(|| format!("Failed to read file '{}'", file_path.display()))?; - // Normalize the path relative to the working directory - let rel_path = file_path.strip_prefix(working_dir).unwrap_or(&file_path).to_path_buf(); - let normalized_name = if rel_path.as_os_str().is_empty() { - file_path.file_name().map(PathBuf::from).unwrap_or_else(|| file_path.clone()) - } else { - rel_path - }; - let mut params = HashMap::new(); - params.insert("file_name".to_string(), sanitize_path(&normalized_name.to_string_lossy())); - let response = reana.post( - &WorkflowEndpoint::Workspace(workflow_name, None), - Content::OctetStream(file_content), - Some(params), - )?; - let _ = response.text().context("Failed to read server response after upload")?; - } - - Ok(()) -} - pub fn get_workflow_workspace(reana_server: &str, reana_token: &str, workflow_id: &str) -> Result { let response = Reana::new(reana_server.to_string(), reana_token.to_string()).get(&WorkflowEndpoint::Workspace(workflow_id, None))?; @@ -603,7 +551,7 @@ mod tests { write(dummy_cwl.path(), "cwlVersion: v1.2").unwrap(); let url = &server.base_url(); let reana = Reana::new(url.to_string(), reana_token.to_string()); - let result = upload_files(&reana, &None, &dummy_cwl.path().to_path_buf(), workflow_name, &workflow_json); + let result = upload_files(&reana, &None, &dummy_cwl.path().to_path_buf(), workflow_name, &workflow_json, None); assert!(result.is_ok(), "upload_files failed: {:?}", result.err()); _mock_upload.assert_calls(3); diff --git a/packages/remote_execution/src/lib.rs b/packages/remote_execution/src/lib.rs index 579f96d9..d4a62a7a 100644 --- a/packages/remote_execution/src/lib.rs +++ b/packages/remote_execution/src/lib.rs @@ -1,9 +1,9 @@ -use std::path::PathBuf; +use std::path::{PathBuf, Path}; use reana_ext::parser::WorkflowJson; use anyhow::Result; mod reana; -pub fn schedule_run(file: &PathBuf, input_file: &Option) -> Result> { +pub fn schedule_run(file: &Path, input_file: &Option) -> Result> { reana::execute_remote_start(file, input_file) } diff --git a/packages/remote_execution/src/reana/compatibility.rs b/packages/remote_execution/src/reana/compatibility.rs index 8ba7e6b8..50df7c7e 100644 --- a/packages/remote_execution/src/reana/compatibility.rs +++ b/packages/remote_execution/src/reana/compatibility.rs @@ -4,25 +4,56 @@ use log::{info, warn}; use reana_ext::parser::WorkflowJson; use std::collections::HashMap; use std::process::{Command as SystemCommand, Stdio}; -use std::{env, fs, path::Path}; +use std::{env, fs, path::Path, thread}; use util::{handle_process, is_docker_installed}; +use std::time::Duration; +use anyhow::anyhow; use s4n_core::parser::SCRIPT_EXECUTORS; -/// Performs some compatibility adjustments on workflow json for the exeuction using REANA. pub fn compatibility_adjustments(workflow_json: &mut WorkflowJson) -> anyhow::Result<()> { + let mut docker_jobs: Vec = Vec::new(); for item in &mut workflow_json.workflow.specification.graph { if let CWLDocument::CommandLineTool(tool) = item { adjust_basecommand(tool)?; - // if tool has a docker pull not necessary to inject a docker pull? if !has_docker_pull(tool) { - publish_docker_ephemeral(tool)?; - if !has_docker_pull(tool) { - inject_docker_pull(tool)?; + docker_jobs.push(tool.clone()); + } + } + } + if docker_jobs.is_empty() { + return Ok(()); + } + let handles: Vec<_> = docker_jobs + .into_iter() + .map(|mut tool| { + thread::spawn(move || -> anyhow::Result { + if !has_docker_pull(&tool) { + publish_docker_ephemeral(&mut tool)?; + if !has_docker_pull(&tool) { + inject_docker_pull(&mut tool)?; + } } + Ok(tool) + }) + }) + .collect(); + let mut updated_tools = Vec::new(); + for handle in handles { + match handle.join() { + Ok(Ok(tool)) => updated_tools.push(tool), + Ok(Err(e)) => return Err(anyhow!("❌ Docker build failed: {e}")), + Err(_) => return Err(anyhow!("❌ Thread panicked during Docker build")), + } + } + for updated_tool in updated_tools { + for item in &mut workflow_json.workflow.specification.graph { + if let CWLDocument::CommandLineTool(tool) = item && tool.id == updated_tool.id { + *tool = updated_tool.clone(); } } } + thread::sleep(Duration::from_secs(5)); Ok(()) } diff --git a/packages/remote_execution/src/reana/workflow.rs b/packages/remote_execution/src/reana/workflow.rs index 0524d848..5aecedff 100644 --- a/packages/remote_execution/src/reana/workflow.rs +++ b/packages/remote_execution/src/reana/workflow.rs @@ -5,9 +5,9 @@ use reana_ext::{ reana::Reana, }; use s4n_core::config; -use std::{collections::HashMap, error::Error, fs, path::PathBuf}; +use std::{collections::HashMap, error::Error, fs, path::{PathBuf, Path}}; -pub fn execute_remote_start(file: &PathBuf, input_file: &Option) -> Result> { +pub fn execute_remote_start(file: &Path, input_file: &Option) -> Result> { let config_path = PathBuf::from("workflow.toml"); let config: Option = if config_path.exists() { Some(toml::from_str(&fs::read_to_string(&config_path)?)?) @@ -20,7 +20,6 @@ pub fn execute_remote_start(file: &PathBuf, input_file: &Option) -> Res let (reana_instance, reana_token) = login_reana()?; let reana = Reana::new(reana_instance.clone(), reana_token.clone()); - // Ping let ping_status = ping_reana(&reana)?; if ping_status.get("status").and_then(|s| s.as_str()) != Some("200") { @@ -37,7 +36,7 @@ pub fn execute_remote_start(file: &PathBuf, input_file: &Option) -> Res let Some(workflow_name) = create_response["workflow_name"].as_str() else { return Err("Missing workflow_name in response".into()); }; - upload_files(&reana, input_file, file, workflow_name, &workflow_json)?; + upload_files(&reana, input_file, file, workflow_name, &workflow_json, None)?; start_workflow(&reana, workflow_name, None, None, false, &converted_yaml)?; eprintln!("✅ Started workflow execution"); From af960cef29469a90f727787c76bb6e31fc5d84a3 Mon Sep 17 00:00:00 2001 From: aleidel Date: Mon, 2 Feb 2026 11:40:54 +0100 Subject: [PATCH 03/16] small adjustment for files without docker req --- packages/remote_execution/src/reana/compatibility.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/remote_execution/src/reana/compatibility.rs b/packages/remote_execution/src/reana/compatibility.rs index 50df7c7e..957723f2 100644 --- a/packages/remote_execution/src/reana/compatibility.rs +++ b/packages/remote_execution/src/reana/compatibility.rs @@ -181,14 +181,13 @@ fn inject_docker_pull(tool: &mut CommandLineTool) -> anyhow::Result<()> { let default_images = HashMap::from([("python", "python"), ("Rscript", "r-base"), ("node", "node")]); - if SCRIPT_EXECUTORS.contains(&&*command_vec[0]) && tool.get_requirement::().is_some() { + if SCRIPT_EXECUTORS.contains(&&*command_vec[0]) { //is script executor but does not use containerization warn!( "Tool {} is using {} and does not use a proper container", id.green().bold(), command_vec[0].bold() ); - if let Some(container) = default_images.get(&&*command_vec[0]) { tool.requirements .push(Requirement::DockerRequirement(DockerRequirement::from_pull(container))); From 98083f874b694fdd6220267e30305cacbe16ed37 Mon Sep 17 00:00:00 2001 From: aleidel Date: Mon, 2 Feb 2026 17:18:36 +0100 Subject: [PATCH 04/16] clippy --- packages/reana/src/api.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/reana/src/api.rs b/packages/reana/src/api.rs index db294ffa..918e66d7 100644 --- a/packages/reana/src/api.rs +++ b/packages/reana/src/api.rs @@ -265,7 +265,7 @@ mod tests { .with_body(r#"{"status": "ok"}"#) .create(); let url = &server.url(); - let reana = Reana::new(url.to_string(), "".to_string()); + let reana = Reana::new(url.to_string(), String::new()); let response: Value = ping_reana(&reana).unwrap(); assert_eq!(response["status"], "ok"); @@ -551,7 +551,7 @@ mod tests { write(dummy_cwl.path(), "cwlVersion: v1.2").unwrap(); let url = &server.base_url(); let reana = Reana::new(url.to_string(), reana_token.to_string()); - let result = upload_files(&reana, &None, &dummy_cwl.path().to_path_buf(), workflow_name, &workflow_json, None); + let result = upload_files(&reana, &None, dummy_cwl.path(), workflow_name, &workflow_json, None); assert!(result.is_ok(), "upload_files failed: {:?}", result.err()); _mock_upload.assert_calls(3); From 1d9e01c661349ad053eb196bbf41ef9566363fa9 Mon Sep 17 00:00:00 2001 From: aleidel Date: Tue, 3 Feb 2026 10:50:09 +0100 Subject: [PATCH 05/16] changes --- packages/core/src/visualize.rs | 7 +++--- packages/gui/src/components/files/mod.rs | 28 ++++++++++++++++++------ packages/reana/src/reana.rs | 5 ++--- packages/reana/src/utils.rs | 27 ++++++++++++++++++----- 4 files changed, 49 insertions(+), 18 deletions(-) diff --git a/packages/core/src/visualize.rs b/packages/core/src/visualize.rs index 10e1811b..d2f44c55 100644 --- a/packages/core/src/visualize.rs +++ b/packages/core/src/visualize.rs @@ -30,10 +30,11 @@ pub fn render(r: &mut R, cwl: &Workflow, filename: &Path, if !no_defaults && let Some(doc) = load_step(step, filename) { for input in &doc.inputs { - if !step.in_.iter().any(|i| i.id == input.id) && input.default.is_some() { + if let Some(default) = input.default.as_ref() + && !step.in_.iter().any(|i| i.id == input.id) + { let node_id = format!("{}_{}", step.id, input.id); - r.node(&node_id, Some(&input.default.as_ref().unwrap().as_value_string()), RenderStyle::Small); - r.edge(&node_id, &step.id, Some(&input.id), RenderStyle::Small); + r.node(&node_id, Some(&default.as_value_string()), RenderStyle::Small); } } } diff --git a/packages/gui/src/components/files/mod.rs b/packages/gui/src/components/files/mod.rs index 0a5f18e5..2fde0790 100644 --- a/packages/gui/src/components/files/mod.rs +++ b/packages/gui/src/components/files/mod.rs @@ -79,10 +79,18 @@ pub fn get_route(node: &Node) -> Route { } pub fn read_node_type(path: impl AsRef) -> FileType { - if path.as_ref().is_dir() || path.as_ref().extension() != Some(OsStr::new("cwl")) { + let path = path.as_ref(); + if path.is_dir() || path.extension() != Some(OsStr::new("cwl")) { return FileType::Other; } - let content = std::fs::read_to_string(path).expect("Can not read file!"); + let safe_path = match path.canonicalize() { + Ok(p) => p, + Err(_) => return FileType::Other, + }; + let content = match std::fs::read_to_string(&safe_path) { + Ok(c) => c, + Err(_) => return FileType::Other, + }; let yaml: Value = serde_yaml::from_str(&content).unwrap_or(Value::Null); match yaml.get("class").and_then(|v| v.as_str()) { @@ -99,11 +107,17 @@ mod tests { #[test] fn test_read_node_type() { - let base = std::env::var("CARGO_MANIFEST_DIR").unwrap(); - let path = format!("{}/../../testdata/hello_world/workflows/main/main.cwl", base); - assert_eq!(read_node_type(path), FileType::Workflow); + let base = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let main_cwl = base + .join("../../testdata/hello_world/workflows/main/main.cwl") + .canonicalize() + .expect("Test file not found"); + assert_eq!(read_node_type(main_cwl), FileType::Workflow); - let path = format!("{}/../../testdata/hello_world/workflows/calculation/calculation.cwl", base); - assert_eq!(read_node_type(path), FileType::CommandLineTool); + let calc_cwl = base + .join("../../testdata/hello_world/workflows/calculation/calculation.cwl") + .canonicalize() + .expect("Test file not found"); + assert_eq!(read_node_type(calc_cwl), FileType::CommandLineTool); } } diff --git a/packages/reana/src/reana.rs b/packages/reana/src/reana.rs index 141925c7..60eea801 100644 --- a/packages/reana/src/reana.rs +++ b/packages/reana/src/reana.rs @@ -39,7 +39,7 @@ impl Reana { .parse()?, ); - let client = Client::builder().default_headers(headers).danger_accept_invalid_certs(true).build()?; + let client = Client::builder().default_headers(headers).build()?; let url = self.url(endpoint, params); match body { Content::Json(json) => client.post(&url).json(&json).send()?.error_for_status(), @@ -49,7 +49,7 @@ impl Reana { } pub fn get(&self, endpoint: &WorkflowEndpoint) -> anyhow::Result { - let client = Client::builder().danger_accept_invalid_certs(true).build()?; + let client = Client::builder().build()?; let url = self.url(endpoint, None); client .get(&url) @@ -60,7 +60,6 @@ impl Reana { pub fn ping(&self) -> anyhow::Result { let ping_url = format!("{}/api/ping", self.server); let client = Client::builder() - .danger_accept_invalid_certs(true) .build() .context("Failed to build HTTP client")?; diff --git a/packages/reana/src/utils.rs b/packages/reana/src/utils.rs index 9e9bbd50..129ac356 100644 --- a/packages/reana/src/utils.rs +++ b/packages/reana/src/utils.rs @@ -106,15 +106,32 @@ pub fn file_matches(requested_file: &str, candidate_path: &str) -> bool { } pub fn collect_files_recursive(dir: &Path, files: &mut HashSet) -> Result<()> { - for entry in fs::read_dir(dir)? { + let root = dir + .canonicalize() + .with_context(|| format!("Failed to canonicalize directory: {}", dir.display()))?; + + collect_files_recursive_inner(&root, &root, files) +} + +fn collect_files_recursive_inner(root: &Path, dir: &Path, files: &mut HashSet) -> Result<()> { + let canonical_dir = dir + .canonicalize() + .with_context(|| format!("Failed to canonicalize directory during traversal: {}", dir.display()))?; + if !canonical_dir.starts_with(root) { + return Err(anyhow!( + "Attempted to traverse outside of root directory: {} (root: {})", + canonical_dir.display(), + root.display() + )); + } + for entry in fs::read_dir(&canonical_dir)? { let entry = entry?; let path = entry.path(); if path.is_dir() { if let Some(name) = path.file_name().and_then(|n| n.to_str()) && name.starts_with('.') { - continue; - } - - collect_files_recursive(&path, files)?; + continue; + } + collect_files_recursive_inner(root, &path, files)?; } else if let Some(path_str) = path.to_str() { files.insert(path_str.to_string()); } From 0fe8ca8afb6cc23b725b1455740de1ead0949790 Mon Sep 17 00:00:00 2001 From: aleidel Date: Tue, 3 Feb 2026 11:01:25 +0100 Subject: [PATCH 06/16] minor change --- packages/gui/Dioxus.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gui/Dioxus.toml b/packages/gui/Dioxus.toml index 248d93a5..4beaaf0a 100644 --- a/packages/gui/Dioxus.toml +++ b/packages/gui/Dioxus.toml @@ -9,7 +9,7 @@ icon = ["assets/icon.ico", "assets/icon.png"] category = "Productivity" [bundle.windows] -icon_path = "packages/gui/assets/icon.ico" +icon_path = "assets/icon.ico" [web.app] title = "SciWIn Studio" From bed3a200dfc1ed10fdb5f1b06f4de24ecceb32ef Mon Sep 17 00:00:00 2001 From: aleidel Date: Wed, 11 Feb 2026 14:59:26 +0100 Subject: [PATCH 07/16] check if Docker is running --- packages/remote_execution/src/reana/compatibility.rs | 11 +++++++++++ packages/remote_execution/src/reana/workflow.rs | 6 +++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/remote_execution/src/reana/compatibility.rs b/packages/remote_execution/src/reana/compatibility.rs index 957723f2..c5a5be9e 100644 --- a/packages/remote_execution/src/reana/compatibility.rs +++ b/packages/remote_execution/src/reana/compatibility.rs @@ -13,6 +13,9 @@ use s4n_core::parser::SCRIPT_EXECUTORS; pub fn compatibility_adjustments(workflow_json: &mut WorkflowJson) -> anyhow::Result<()> { let mut docker_jobs: Vec = Vec::new(); + if !is_docker_available() { + return Err(anyhow!("❌ Docker is not running or not accessible.")); + } for item in &mut workflow_json.workflow.specification.graph { if let CWLDocument::CommandLineTool(tool) = item { adjust_basecommand(tool)?; @@ -57,6 +60,14 @@ pub fn compatibility_adjustments(workflow_json: &mut WorkflowJson) -> anyhow::Re Ok(()) } +fn is_docker_available() -> bool { + std::process::Command::new("docker") + .arg("info") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status().map(|s| s.success()).unwrap_or(false) +} + ///checks if tool has a docker pull already fn has_docker_pull(tool: &CommandLineTool) -> bool { tool.requirements.iter().any(|req| { diff --git a/packages/remote_execution/src/reana/workflow.rs b/packages/remote_execution/src/reana/workflow.rs index 5aecedff..253ed854 100644 --- a/packages/remote_execution/src/reana/workflow.rs +++ b/packages/remote_execution/src/reana/workflow.rs @@ -27,7 +27,11 @@ pub fn execute_remote_start(file: &Path, input_file: &Option) -> Result } // Generate worfklow.json let mut workflow_json = generate_workflow_json_from_cwl(file, input_file)?; - compatibility_adjustments(&mut workflow_json)?; + + if let Err(e) = compatibility_adjustments(&mut workflow_json) { + eprintln!("❌ Compatibility adjustment failed: {e}"); + std::process::exit(1); + } let workflow_json = serde_json::to_value(workflow_json)?; let converted_yaml: serde_yaml::Value = serde_json::from_value(workflow_json.clone())?; From e4f8835782b322851198eec405afe01fe7068d8b Mon Sep 17 00:00:00 2001 From: aleidel Date: Thu, 12 Feb 2026 09:34:56 +0100 Subject: [PATCH 08/16] improved output for user for remote execute download and start --- packages/cli/src/commands/execute.rs | 2 +- packages/remote_execution/src/reana/workflow.rs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/execute.rs b/packages/cli/src/commands/execute.rs index 8d5a763d..d4f32e1e 100644 --- a/packages/cli/src/commands/execute.rs +++ b/packages/cli/src/commands/execute.rs @@ -86,7 +86,7 @@ pub enum RemoteSubcommands { #[arg(help = "Workflow name to check (if omitted, checks all)")] workflow_name: Option, }, - #[command(about = "Downloads finished Workflow from REANA")] + #[command(about = "Downloads workflow outputs from REANA")] Download { #[arg(help = "Workflow name to download results for")] workflow_name: String, diff --git a/packages/remote_execution/src/reana/workflow.rs b/packages/remote_execution/src/reana/workflow.rs index 253ed854..ddaab7c9 100644 --- a/packages/remote_execution/src/reana/workflow.rs +++ b/packages/remote_execution/src/reana/workflow.rs @@ -42,7 +42,8 @@ pub fn execute_remote_start(file: &Path, input_file: &Option) -> Result }; upload_files(&reana, input_file, file, workflow_name, &workflow_json, None)?; start_workflow(&reana, workflow_name, None, None, false, &converted_yaml)?; - eprintln!("✅ Started workflow execution"); + eprintln!("✅ Started workflow execution {workflow_name} on REANA instance {reana_instance}"); + eprintln!("You can check its status using: s4n execute remote status '{workflow_name}' or use 's4n execute remote status' to check all workflows on REANA instance {reana_instance}"); save_workflow_name(&reana_instance, workflow_name)?; Ok(workflow_name.to_owned()) From 190a8547a0d10473197f4fb1d1361b9a6fe8bab3 Mon Sep 17 00:00:00 2001 From: aleidel Date: Thu, 12 Feb 2026 16:43:49 +0100 Subject: [PATCH 09/16] fixed bug with status for rocrate download, added option to download entire workspace --- packages/cli/src/commands/execute.rs | 4 +- packages/reana/src/api.rs | 32 +++++------- packages/remote_execution/src/lib.rs | 4 +- .../remote_execution/src/reana/download.rs | 50 +++++++++++++------ .../remote_execution/src/reana/rocrate.rs | 9 ++-- packages/remote_execution/src/reana/status.rs | 6 +-- .../remote_execution/src/reana/workflow.rs | 5 +- 7 files changed, 61 insertions(+), 49 deletions(-) diff --git a/packages/cli/src/commands/execute.rs b/packages/cli/src/commands/execute.rs index d4f32e1e..64b73ee1 100644 --- a/packages/cli/src/commands/execute.rs +++ b/packages/cli/src/commands/execute.rs @@ -18,7 +18,7 @@ pub fn handle_execute_commands(subcommand: &ExecuteCommands) -> Result<(), Box schedule_run(file, input_file, *rocrate, *watch, *logout), RemoteSubcommands::Status { workflow_name } => check_status(workflow_name), - RemoteSubcommands::Download { workflow_name, output_dir } => download_results(workflow_name, output_dir.as_ref()), + RemoteSubcommands::Download { workflow_name, all, output_dir } => download_results(workflow_name, *all, output_dir.as_ref()), RemoteSubcommands::Rocrate { workflow_name, output_dir } => export_rocrate(workflow_name, output_dir.as_ref()), RemoteSubcommands::Logout => logout(), }, @@ -90,6 +90,8 @@ pub enum RemoteSubcommands { Download { #[arg(help = "Workflow name to download results for")] workflow_name: String, + #[arg(short = 'a', long = "all", help = "Download all files of the workflow")] + all: bool, #[arg(short = 'd', long = "output_dir", help = "Optional output directory to save downloaded files")] output_dir: Option, }, diff --git a/packages/reana/src/api.rs b/packages/reana/src/api.rs index 918e66d7..97dbe2d0 100644 --- a/packages/reana/src/api.rs +++ b/packages/reana/src/api.rs @@ -201,32 +201,24 @@ pub fn download_files(reana: &Reana, workflow_name: &str, files: &[String], fold eprintln!("ℹ️ No files to download."); return Ok(()); } - - if let Some(ref dir) = folder { - fs::create_dir_all(dir).with_context(|| format!("❌ Failed to create folder: {dir}"))?; - } - for file_name in files { let response = reana.get(&WorkflowEndpoint::Workspace(workflow_name, Some(file_name.to_string())))?; - if response.status().is_success() { - let file_path_name = Path::new(file_name) - .file_name() - .and_then(|f| f.to_str()) - .context("❌ Invalid or missing UTF-8 file name")? - .to_string(); - + // reana adds all outputs in an outputs/ folder, remove this for now + let relative_path = file_name.strip_prefix("outputs/").unwrap_or(file_name); let output_path = match folder { - Some(dir) => Path::new(dir).join(&file_path_name), - None => PathBuf::from(&file_path_name), + Some(dir) => Path::new(dir).join(relative_path), + None => PathBuf::from(relative_path), }; - + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("❌ Failed to create folder: {}", parent.display()))?; + } let content = response.bytes().context("❌ Failed to read response bytes")?; - - let mut file = File::create(&output_path).with_context(|| format!("❌ Failed to create file: {}", output_path.display()))?; + let mut file = File::create(&output_path) + .with_context(|| format!("❌ Failed to create file: {}", output_path.display()))?; file.write_all(&content) .with_context(|| format!("❌ Failed to write to file: {}", output_path.display()))?; - eprintln!("✅ Downloaded: {}", output_path.display()); } else { let error_text = response.text().unwrap_or_else(|_| "Unknown error".to_string()); @@ -237,8 +229,8 @@ pub fn download_files(reana: &Reana, workflow_name: &str, files: &[String], fold Ok(()) } -pub fn get_workflow_workspace(reana_server: &str, reana_token: &str, workflow_id: &str) -> Result { - let response = Reana::new(reana_server.to_string(), reana_token.to_string()).get(&WorkflowEndpoint::Workspace(workflow_id, None))?; +pub fn get_workflow_workspace(reana: &Reana, workflow_id: &str) -> Result { + let response = reana.get(&WorkflowEndpoint::Workspace(workflow_id, None))?; let json_response: Value = response.json().context("❌ Failed to parse JSON response")?; diff --git a/packages/remote_execution/src/lib.rs b/packages/remote_execution/src/lib.rs index d4a62a7a..6822b0d8 100644 --- a/packages/remote_execution/src/lib.rs +++ b/packages/remote_execution/src/lib.rs @@ -11,8 +11,8 @@ pub fn check_status(workflow_name: &Option) -> Result<(), Box) -> Result<(), Box> { - reana::download_remote_results(workflow_name, output_dir) +pub fn download_results(workflow_name: &str, all: bool, output_dir: Option<&String>) -> Result<(), Box> { + reana::download_remote_results(workflow_name, all, output_dir) } pub fn export_rocrate(workflow_name: &str, output_dir: Option<&String>) -> Result<(), Box> { diff --git a/packages/remote_execution/src/reana/download.rs b/packages/remote_execution/src/reana/download.rs index ed6d5bce..4c73389a 100644 --- a/packages/remote_execution/src/reana/download.rs +++ b/packages/remote_execution/src/reana/download.rs @@ -1,11 +1,11 @@ use crate::reana::{auth::login_reana, workflow::analyze_workflow_logs}; use reana_ext::{ - api::{download_files, get_workflow_specification, get_workflow_status}, + api::{download_files, get_workflow_specification, get_workflow_status, get_workflow_workspace}, reana::Reana, }; use std::error::Error; -pub fn download_remote_results(workflow_name: &str, output_dir: Option<&String>) -> Result<(), Box> { +pub fn download_remote_results(workflow_name: &str, all: bool, output_dir: Option<&String>) -> Result<(), Box> { let (reana_instance, reana_token) = login_reana()?; let reana = Reana::new(reana_instance, reana_token); @@ -14,20 +14,38 @@ pub fn download_remote_results(workflow_name: &str, output_dir: Option<&String>) // Get workflow status, only download if finished? match workflow_status { "finished" => { - let workflow_json = get_workflow_specification(&reana, workflow_name)?; - let output_files = workflow_json - .get("specification") - .and_then(|spec| spec.get("outputs")) - .and_then(|outputs| outputs.get("files")) - .and_then(|files| files.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str()) - .map(|filename| format!("outputs/{filename}")) - .collect::>() - }) - .unwrap_or_default(); - download_files(&reana, workflow_name, &output_files, output_dir.map(|x| x.as_str()))?; + // Download only outputs + if !all { + let workflow_json = get_workflow_specification(&reana, workflow_name)?; + let output_files = workflow_json + .get("specification") + .and_then(|spec| spec.get("outputs")) + .and_then(|outputs| outputs.get("files")) + .and_then(|files| files.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .map(|filename| format!("outputs/{filename}")) + .collect::>() + }) + .unwrap_or_default(); + download_files(&reana, workflow_name, &output_files, output_dir.map(|x| x.as_str()))?; + } + // Download all files in workspace + else { + let workspace_json = get_workflow_workspace(&reana, workflow_name)?; + let workspace_files: Vec = workspace_json + .get("items") + .and_then(|items| items.as_array()) + .map(|array| array.iter().filter_map(|item| item.get("name")?.as_str().map(String::from)).collect()) + .unwrap_or_default(); + if workspace_files.is_empty() { + eprintln!("⚠️ No files found in workspace for workflow '{workflow_name}'."); + } + else { + download_files(&reana, workflow_name, &workspace_files, output_dir.map(|x| x.as_str()))?; + } + } } "failed" => { if let Some(logs_str) = status_response["logs"].as_str() { diff --git a/packages/remote_execution/src/reana/rocrate.rs b/packages/remote_execution/src/reana/rocrate.rs index 67f2633d..4446213a 100644 --- a/packages/remote_execution/src/reana/rocrate.rs +++ b/packages/remote_execution/src/reana/rocrate.rs @@ -1,4 +1,4 @@ -use crate::reana::{auth::login_reana, workflow::analyze_workflow_logs}; +use crate::reana::{auth::login_reana, workflow::analyze_workflow_logs, status::evaluate_workflow_status}; use reana_ext::{ api::{get_workflow_logs, get_workflow_specification, get_workflow_status, get_workflow_workspace}, reana::Reana, @@ -11,9 +11,8 @@ pub fn export_rocrate(workflow_name: &str, ro_crate_dir: Option<&String>) -> Res let reana = Reana::new(reana_instance.clone(), reana_token.clone()); // Get workflow status, only export if finished? - let status_response = get_workflow_logs(&reana, workflow_name).map_err(|e| format!("Failed to fetch workflow status: {e}"))?; - let workflow_status = status_response["status"].as_str().unwrap_or("unknown"); - match workflow_status { + let workflow_status = evaluate_workflow_status(&reana, workflow_name, false)?; + match workflow_status.as_str() { "finished" => { let workflow_json = get_workflow_specification(&reana, workflow_name)?; let config_path = PathBuf::from("workflow.toml"); @@ -29,7 +28,7 @@ pub fn export_rocrate(workflow_name: &str, ro_crate_dir: Option<&String>) -> Res "https://w3id.org/ro/wfrun/provenance/0.5", "https://w3id.org/workflowhub/workflow-ro-crate/1.0", ]; - let workspace_response = get_workflow_workspace(&reana_instance, &reana_token, workflow_name)?; + let workspace_response = get_workflow_workspace(&reana, workflow_name)?; let workspace_files: Vec = workspace_response .get("items") .and_then(|items| items.as_array()) diff --git a/packages/remote_execution/src/reana/status.rs b/packages/remote_execution/src/reana/status.rs index 70ff9c02..b9c830d1 100644 --- a/packages/remote_execution/src/reana/status.rs +++ b/packages/remote_execution/src/reana/status.rs @@ -27,7 +27,7 @@ pub fn check_remote_status(workflow_name: &Option) -> Result<(), Box Result<(), Box> { +pub fn evaluate_workflow_status(reana: &Reana, name: &str, analyze_logs: bool) -> Result> { let status_response = get_workflow_status(reana, name).map_err(|e| format!("Failed to fetch workflow status: {e}"))?; let status = status_response["status"].as_str().unwrap_or("unknown"); let created = status_response["created"].as_str().unwrap_or("unknown"); @@ -46,7 +46,7 @@ fn evaluate_workflow_status(reana: &Reana, name: &str, analyze_logs: bool) -> Re { analyze_workflow_logs(logs_str); } - Ok(()) + Ok(status.to_string()) } pub fn watch(workflow_name: &str, rocrate: bool) -> Result<(), Box> { @@ -63,7 +63,7 @@ pub fn watch(workflow_name: &str, rocrate: bool) -> Result<(), Box> { match workflow_status { "finished" => { eprintln!("✅ Workflow finished successfully."); - if let Err(e) = crate::reana::download_remote_results(workflow_name, None) { + if let Err(e) = crate::reana::download_remote_results(workflow_name, false, None) { eprintln!("Error downloading remote results: {e}"); } if rocrate && let Err(e) = export_rocrate(workflow_name, Some(&"rocrate".to_string())) { diff --git a/packages/remote_execution/src/reana/workflow.rs b/packages/remote_execution/src/reana/workflow.rs index ddaab7c9..10a83071 100644 --- a/packages/remote_execution/src/reana/workflow.rs +++ b/packages/remote_execution/src/reana/workflow.rs @@ -40,9 +40,10 @@ pub fn execute_remote_start(file: &Path, input_file: &Option) -> Result let Some(workflow_name) = create_response["workflow_name"].as_str() else { return Err("Missing workflow_name in response".into()); }; - upload_files(&reana, input_file, file, workflow_name, &workflow_json, None)?; + let working_dir = std::env::current_dir()?; + upload_files(&reana, input_file, file, workflow_name, &workflow_json, Some(&working_dir))?; start_workflow(&reana, workflow_name, None, None, false, &converted_yaml)?; - eprintln!("✅ Started workflow execution {workflow_name} on REANA instance {reana_instance}"); + eprintln!("✅ Started workflow execution of '{workflow_name}' on REANA instance {reana_instance}"); eprintln!("You can check its status using: s4n execute remote status '{workflow_name}' or use 's4n execute remote status' to check all workflows on REANA instance {reana_instance}"); save_workflow_name(&reana_instance, workflow_name)?; From ae9a40f3a6cfe6921b672b67340518b96c852777 Mon Sep 17 00:00:00 2001 From: aleidel Date: Thu, 12 Feb 2026 17:06:29 +0100 Subject: [PATCH 10/16] removed outputs --- packages/remote_execution/src/reana/download.rs | 5 ----- packages/remote_execution/src/reana/workflow.rs | 4 ++-- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/remote_execution/src/reana/download.rs b/packages/remote_execution/src/reana/download.rs index 4c73389a..e911eb82 100644 --- a/packages/remote_execution/src/reana/download.rs +++ b/packages/remote_execution/src/reana/download.rs @@ -39,12 +39,7 @@ pub fn download_remote_results(workflow_name: &str, all: bool, output_dir: Optio .and_then(|items| items.as_array()) .map(|array| array.iter().filter_map(|item| item.get("name")?.as_str().map(String::from)).collect()) .unwrap_or_default(); - if workspace_files.is_empty() { - eprintln!("⚠️ No files found in workspace for workflow '{workflow_name}'."); - } - else { download_files(&reana, workflow_name, &workspace_files, output_dir.map(|x| x.as_str()))?; - } } } "failed" => { diff --git a/packages/remote_execution/src/reana/workflow.rs b/packages/remote_execution/src/reana/workflow.rs index 10a83071..d23909aa 100644 --- a/packages/remote_execution/src/reana/workflow.rs +++ b/packages/remote_execution/src/reana/workflow.rs @@ -43,8 +43,8 @@ pub fn execute_remote_start(file: &Path, input_file: &Option) -> Result let working_dir = std::env::current_dir()?; upload_files(&reana, input_file, file, workflow_name, &workflow_json, Some(&working_dir))?; start_workflow(&reana, workflow_name, None, None, false, &converted_yaml)?; - eprintln!("✅ Started workflow execution of '{workflow_name}' on REANA instance {reana_instance}"); - eprintln!("You can check its status using: s4n execute remote status '{workflow_name}' or use 's4n execute remote status' to check all workflows on REANA instance {reana_instance}"); + eprintln!("✅ Started workflow execution of '{workflow_name}'."); + eprintln!("You can check its status using: s4n execute remote status '{workflow_name}' or use 's4n execute remote status' to check all workflows."); save_workflow_name(&reana_instance, workflow_name)?; Ok(workflow_name.to_owned()) From 3300627aad57bcaaf4c0755109e6152f985f16ae Mon Sep 17 00:00:00 2001 From: aleidel Date: Fri, 13 Feb 2026 09:22:59 +0100 Subject: [PATCH 11/16] cloud button only next to workflows --- packages/gui/src/components/files/solution.rs | 54 ++++++++++--------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/packages/gui/src/components/files/solution.rs b/packages/gui/src/components/files/solution.rs index 5fcffec1..3d929ac0 100644 --- a/packages/gui/src/components/files/solution.rs +++ b/packages/gui/src/components/files/solution.rs @@ -14,6 +14,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::time::Duration; use commonwl::execution::execute_cwlfile; +use crate::components::files::{FileType, read_node_type}; #[component] pub fn SolutionView(project_path: ReadSignal, dialog_signals: (Signal, Signal)) -> Element { @@ -145,37 +146,38 @@ pub fn SolutionView(project_path: ReadSignal, dialog_signals: (Signal dir, - None => { - eprintln!("❌ No working directory set"); - return Ok(()); - } - }; - spawn(async move { - execute_reana_workflow(item, working_dir, show_settings).await; - }); - Ok(()) + let app_state = app_state; + + move |_| { + let item = item.clone(); + let show_settings = show_settings; + let working_dir = match app_state().working_directory.clone() { + Some(dir) => dir, + None => { + eprintln!("❌ No working directory set"); + return Ok(()); + } + }; + spawn(async move { + execute_reana_workflow(item, working_dir, show_settings).await; + }); + Ok(()) + } + }, + Icon { + width: 10, + height: 10, + icon: GoCloud, } - }, - Icon { - width: 10, - height: 10, - icon: GoCloud, } } - } } } From 3b52d40d85b8ff0e5ed6537bc92c57ace79a4010 Mon Sep 17 00:00:00 2001 From: aleidel Date: Mon, 2 Mar 2026 22:36:42 +0100 Subject: [PATCH 12/16] improved execution --- packages/gui/src/components/files/solution.rs | 166 +++++----- packages/gui/src/components/mod.rs | 2 + packages/gui/src/components/terminal.rs | 288 ++++++++++++++++++ packages/gui/src/layout.rs | 28 +- packages/gui/src/lib.rs | 11 + packages/gui/src/reana_integration.rs | 265 ++++++++++++---- packages/remote_execution/Cargo.toml | 1 + packages/remote_execution/src/lib.rs | 27 +- .../src/reana/compatibility.rs | 264 ++++++++-------- packages/remote_execution/src/reana/mod.rs | 2 +- .../remote_execution/src/reana/rocrate.rs | 8 +- packages/remote_execution/src/reana/status.rs | 2 +- .../remote_execution/src/reana/workflow.rs | 83 +++-- 13 files changed, 826 insertions(+), 321 deletions(-) create mode 100644 packages/gui/src/components/terminal.rs diff --git a/packages/gui/src/components/files/solution.rs b/packages/gui/src/components/files/solution.rs index 3d929ac0..4341d3aa 100644 --- a/packages/gui/src/components/files/solution.rs +++ b/packages/gui/src/components/files/solution.rs @@ -3,10 +3,10 @@ use crate::components::{ICON_SIZE, SmallRoundActionButton}; use crate::files::{get_cwl_files, get_submodules_cwl_files}; use crate::layout::{INPUT_TEXT_CLASSES, RELOAD_TRIGGER, Route}; use crate::use_app_state; -use crate::reana_integration::{execute_reana_workflow, store_reana_credentials}; +use crate::reana_integration::{execute_reana_workflow, get_reana_credentials}; use dioxus::prelude::*; use dioxus_free_icons::Icon; -use dioxus_free_icons::icons::go_icons::{GoCloud, GoFileDirectory, GoPlusCircle, GoTrash, GoGear, GoPlay}; +use dioxus_free_icons::icons::go_icons::{GoCloud, GoFileDirectory, GoPlusCircle, GoTrash, GoPlay}; use repository::Repository; use repository::submodule::{add_submodule, remove_submodule}; use reqwest::Url; @@ -15,17 +15,20 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use commonwl::execution::execute_cwlfile; use crate::components::files::{FileType, read_node_type}; +use crate::components::ExecutionType; +use tokio::sync::mpsc; +use dioxus::core::spawn; #[component] pub fn SolutionView(project_path: ReadSignal, dialog_signals: (Signal, Signal)) -> Element { let mut app_state = use_app_state(); let files = use_memo(move || { - RELOAD_TRIGGER(); //subscribe to changes + RELOAD_TRIGGER(); get_cwl_files(project_path().join("workflows")) }); let submodule_files = use_memo(move || { - RELOAD_TRIGGER(); //subscribe to changes + RELOAD_TRIGGER(); get_submodules_cwl_files(project_path()) }); @@ -33,10 +36,7 @@ pub fn SolutionView(project_path: ReadSignal, dialog_signals: (Signal, dialog_signals: (Signal(64); + tokio::task::spawn_blocking({ + let item = item.clone(); + let args = args.clone(); + let dir = dir.clone(); + let tx = tx.clone(); + move || { + let mut cwl_path = std::path::PathBuf::from(&item.path); + if cwl_path.is_relative() { + cwl_path = std::env::current_dir() + .unwrap_or_default() + .join(&cwl_path); + } + cwl_path = cwl_path.canonicalize().unwrap_or(cwl_path.clone()); + let inputs_file = dir.join("inputs.yml"); + if !cwl_path.exists() { + let _ = tx.blocking_send(format!("❌ CWL file not found: {:?}\n", cwl_path)); + return; + } + if !inputs_file.exists() { + let _ = tx.blocking_send(format!("❌ inputs.yml not found: {:?}\n", inputs_file)); + return; + } + let _ = tx.blocking_send("⚙️ Starting CWL execution...\n".to_string()); + let result = execute_cwlfile(&cwl_path, &args, Some(dir)); + let _ = match result { + Ok(_) => tx.blocking_send("✅ Local execution completed.\n".to_string()), + Err(e) => tx.blocking_send(format!("❌ Execution failed: {e}\n")), + }; + } }); + while let Some(line) = rx.recv().await { + terminal_signal.with_mut(|t| t.push_str(&line)); + } Ok(()) } } }, Icon { width: 10, height: 10, icon: GoPlay } - } - // REANA + } + // REANA if read_node_type(&item.path) == FileType::Workflow { SmallRoundActionButton { class: "hover:bg-fairagro-mid-500", - title: format!("Execute with REANA"), + title: "Execute with REANA".to_string(), onclick: { let item = item.clone(); - let show_settings = show_settings; let app_state = app_state; - move |_| { let item = item.clone(); let show_settings = show_settings; + let mut app_state = app_state; + + app_state.write().active_tab.set("terminal".to_string()); + app_state.write().show_terminal_log.set(true); + app_state.write().terminal_log.set(String::new()); + app_state.write().terminal_exec_type.set(ExecutionType::Remote); + + let creds = get_reana_credentials().ok().flatten(); + if creds.is_none() { + app_state.write().show_manage_reana_modal.set(true); + return Ok(()); + } + let (_instance_url, _token) = creds.unwrap(); let working_dir = match app_state().working_directory.clone() { Some(dir) => dir, None => { @@ -165,29 +216,36 @@ pub fn SolutionView(project_path: ReadSignal, dialog_signals: (Signal(100); spawn(async move { - execute_reana_workflow(item, working_dir, show_settings).await; + while let Some(msg) = rx.recv().await { + let mut log = terminal_signal(); + log.push_str(&msg); + terminal_signal.set(log); + } }); - Ok(()) - } - }, - Icon { - width: 10, - height: 10, - icon: GoCloud, + dioxus::prelude::spawn(async move { + if let Err(e) = execute_reana_workflow(item, working_dir, show_settings, Some(tx)).await { + let mut log = terminal_signal(); + log.push_str(&format!("\n❌ Execution failed: {e}\n")); + terminal_signal.set(log); + } + }); + Ok(()) + } + }, + Icon { width: 10, height: 10, icon: GoCloud } } } } } } } - } - for (module , files) in submodule_files() { - Submodule_View { module, files, dialog_signals } + Submodule_View { module, files, dialog_signals } + } } - } - h2 { class: "mt-2 font-bold flex gap-1 items-center cursor-pointer", onclick: move |_| adding.set(true), @@ -231,56 +289,6 @@ pub fn SolutionView(project_path: ReadSignal, dialog_signals: (Signal, +) -> Element { + let exec_type = exec_type.unwrap_or(ExecutionType::Local); + let app_state = use_app_state(); + let terminal_signal = &app_state().terminal_log; + let show_terminal_log = app_state().show_terminal_log; + let mut toast_items = use_context::>>(); + let show_modal = &app_state().show_manage_reana_modal; + + let title = match exec_type { + ExecutionType::Local => "Local Execution", + ExecutionType::Remote => "Remote Execution", + }; + + rsx! { + div { + id: "terminal", + class: "relative flex flex-col w-full h-full min-h-0 overflow-hidden border border-gray-300 rounded-lg", + div { + class: "flex justify-between items-center bg-gray-100 px-3 py-2 border-b border-gray-300", + h2 { class: "text-sm font-semibold text-gray-700", "{title}" } + if exec_type == ExecutionType::Remote { + div { + class: "flex items-center space-x-1 px-2 py-1 rounded-md select-none", + // Download Results button + SmallRoundActionButton { + onclick: move |_| { + toast_items.write().push(ToastItem::new( + "Download Results".to_string(), + "Downloading files...".to_string(), + 3, + )); + let output_dir: Option = app_state() + .working_directory + .as_ref() + .map(|path| path.to_string_lossy().to_string()); + use_coroutine(move |mut _co: dioxus::prelude::UnboundedReceiver<()>| { + let output_dir = output_dir.clone(); + async move { + let workflow_name: String = match get_last_workflow_name().await { + Ok(name) => name, + Err(e) => { + eprintln!("❌ Failed to get workflow name: {e}"); + return; + } + }; + let result = tokio::task::spawn_blocking(move || { + remote_execution::download_results( + &workflow_name, + false, + output_dir.as_ref(), + ) + .map_err(|e| e.to_string()) + }).await; + match result { + Ok(Ok(())) => eprintln!("✅ Download completed successfully."), + Ok(Err(e)) => eprintln!("❌ Download failed: {e}"), + Err(e) => eprintln!("❌ Task panicked: {e}"), + } + } + }); + }, + div { + class: "flex items-center space-x-1", + Icon { icon: GoDownload, width: ICON_SIZE, height: ICON_SIZE } + span { "Download Results" } + } + } + // Export RO-Crate button + SmallRoundActionButton { + onclick: move |_| { + toast_items.write().push(ToastItem::new( + "Export RO-Crate".to_string(), + "Exporting RO-Crate...".to_string(), + 3, + )); + let working_dir: Option = app_state() + .working_directory + .as_ref() + .map(|path| path.to_string_lossy().to_string()); + let output_dir: Option = working_dir.clone().map(|dir| { + std::path::Path::new(&dir) + .join("rocrate") + .to_string_lossy() + .into_owned() + }); + use_coroutine(move |mut _co: dioxus::prelude::UnboundedReceiver<()>| { + let working_dir = working_dir.clone(); + let output_dir = output_dir.clone(); + async move { + let workflow_name: String = match get_last_workflow_name().await { + Ok(name) => name, + Err(e) => { + eprintln!("❌ Failed to get workflow name: {e}"); + return; + } + }; + let result = tokio::task::spawn_blocking(move || { + export_rocrate( + &workflow_name, + output_dir.as_ref(), + working_dir.as_ref(), + ) + .map_err(|e| e.to_string()) + }).await; + match result { + Ok(Ok(())) => eprintln!("✅ RO-Crate exported successfully."), + Ok(Err(e)) => eprintln!("❌ RO-Crate export failed: {e}"), + Err(e) => eprintln!("❌ Task panicked: {e}"), + } + } + }); + }, + div { + class: "flex items-center space-x-1", + Icon { icon: GoPackage, width: ICON_SIZE, height: ICON_SIZE } + span { "Export RO-Crate" } + } + } + ManageReanaButton { + show_modal: *show_modal + } + } + } + } + if show_terminal_log() { + div { + id: "editor-container", + class: "flex-1 overflow-y-scroll bg-black text-green-400 text-xs font-mono p-2 whitespace-pre-wrap", + pre { "{terminal_signal()}" } + } + } + } + } +} + +#[allow(dead_code)] +struct ReanaInstance { + url: String, + token: String, +} + +#[component] +pub fn ManageReanaButton( + #[props(optional)] show_modal: Option>, +) -> Element { + let mut show_modal = show_modal.unwrap_or(use_signal(|| false)); + let mut instances = use_signal(Vec::::new); + let mut new_url = use_signal(String::new); + let mut new_token = use_signal(String::new); + let mut toast_items = use_context::>>(); + { + use_effect(move || { + match get_reana_credentials() { + Ok(Some((url, token))) => { + instances.write().push(ReanaInstance { url, token }); + } + Ok(None) | Err(_) => { + show_modal.set(true); + } + } + }); + } + + let mut push_toast = move |title: &str, message: String, duration_secs: i64| { + toast_items.write().push(ToastItem::new(title.to_string(), message, duration_secs)); + }; + + let instance_list = { + let list = instances.read(); + if list.is_empty() { + rsx! { p { class: "text-gray-500", "No instances configured." } } + } else { + rsx! { + ul { + class: "space-y-2", + {list.iter().enumerate().map(|(i, instance)| { + rsx! { + li { + key: "{i}", + class: "flex justify-between items-center border p-2 rounded", + div { span { class: "font-medium", "{instance.url}" } } + button { + class: "text-red-600 hover:text-red-800", + onclick: move |_| { + if let Err(e) = delete_reana_credentials() { + push_toast("Error", format!("Failed to delete credentials: {e}"), 3); + } else { + push_toast("Deleted", "REANA credentials removed successfully".to_string(), 2); + instances.write().remove(i); + } + }, + "Delete" + } + } + } + }) } + } + } + } + }; + + rsx! { + SmallRoundActionButton { + onclick: move |_| { + push_toast("Manage REANA Instances", "Opening REANA manager...".to_string(), 3); + show_modal.set(true); + }, + div { + class: "flex items-center space-x-1", + Icon { icon: GoCloud, width: ICON_SIZE, height: ICON_SIZE } + span { "Manage REANA Instances" } + } + } + if show_modal() { + div { + class: "fixed top-20 right-20 bg-white p-6 rounded-lg shadow-lg w-96 space-y-4 z-50", + h2 { class: "text-lg font-bold mb-2", "Configured REANA Instances" } + {instance_list} + + div { + class: "space-y-2 border-t pt-4", + h3 { class: "font-semibold", "Add New Instance" } + input { + class: "w-full border rounded px-2 py-1", + placeholder: "Instance URL", + value: "{new_url()}", + oninput: move |e| new_url.set(e.value()), + } + input { + class: "w-full border rounded px-2 py-1", + placeholder: "API Token", + value: "{new_token()}", + oninput: move |e| new_token.set(e.value()), + } + div { + class: "flex justify-end space-x-2 mt-2", + button { + class: "bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700", + onclick: move |_| { + if !new_url().is_empty() && !new_token().is_empty() { + if let Err(err) = store_reana_credentials(&new_url(), &new_token()) { + push_toast("Error", format!("Failed to store credentials: {}", err), 4); + } else { + push_toast("Success", "REANA credentials saved successfully".to_string(), 2); + instances.write().push(ReanaInstance { url: new_url(), token: new_token() }); + new_url.set(String::new()); + new_token.set(String::new()); + } + } + }, + "Add Instance" + } + button { + class: "bg-gray-400 text-white px-3 py-1 rounded hover:bg-gray-500", + onclick: move |_| show_modal.set(false), + "Close" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/gui/src/layout.rs b/packages/gui/src/layout.rs index 61f674fb..52228f06 100644 --- a/packages/gui/src/layout.rs +++ b/packages/gui/src/layout.rs @@ -1,7 +1,7 @@ use crate::{ ApplicationState, components::{ - CodeViewer, ConfirmDialog, ICON_SIZE, NoProject, NoProjectDialog, OkDialog, RoundActionButton, SmallRoundActionButton, ToolAddForm, + CodeViewer, TerminalViewer, ConfirmDialog, ICON_SIZE, NoProject, NoProjectDialog, OkDialog, RoundActionButton, SmallRoundActionButton, ToolAddForm, WorkflowAddDialog, files::{FilesView, View}, graph::GraphEditor, @@ -321,11 +321,16 @@ pub fn Empty() -> Element { #[component] pub fn WorkflowView(path: String) -> Element { - rsx!( + let mut app_state = use_app_state(); + use_effect(move || { + app_state.write().active_tab.set("editor".to_string()); + }); + rsx! { Tabs { class: "h-full min-h-0", default_value: "editor".to_string(), TabList { TabTrigger { index: 0usize, value: "editor".to_string(), "Nodes" } TabTrigger { index: 1usize, value: "code".to_string(), "Code" } + TabTrigger { index: 2usize, value: "terminal".to_string(), "Terminal" } } TabContent { index: 0usize, @@ -339,16 +344,27 @@ pub fn WorkflowView(path: String) -> Element { value: "code".to_string(), CodeViewer { path: path.clone() } } + TabContent { + index: 2usize, + class: "h-full min-h-0", + value: "terminal".to_string(), + TerminalViewer { exec_type: Some((app_state.read().terminal_exec_type)()) } + } } - ) + } } #[component] pub fn ToolView(path: String) -> Element { + let mut app_state = use_app_state(); + use_effect(move || { + app_state.write().active_tab.set("code".to_string()); + }); rsx! { Tabs { class: "h-full min-h-0", default_value: "code".to_string(), TabList { TabTrigger { index: 0usize, value: "code".to_string(), "Code" } + TabTrigger { index: 2usize, value: "terminal".to_string(), "Terminal" } } TabContent { index: 0usize, @@ -356,6 +372,12 @@ pub fn ToolView(path: String) -> Element { value: "code".to_string(), CodeViewer { path } } + TabContent { + index: 2usize, + class: "h-full min-h-0", + value: "terminal".to_string(), + TerminalViewer { exec_type: Some((app_state.read().terminal_exec_type)()) } + } } } } diff --git a/packages/gui/src/lib.rs b/packages/gui/src/lib.rs index 98cc19c2..08faa179 100644 --- a/packages/gui/src/lib.rs +++ b/packages/gui/src/lib.rs @@ -14,6 +14,7 @@ use std::{ path::{Path, PathBuf}, time::Duration, }; +use crate::components::ExecutionType; pub mod components; pub mod files; @@ -33,6 +34,16 @@ pub struct ApplicationState { pub workflow: VisualWorkflow, #[serde(skip)] data_transfer: serde_json::Value, + #[serde(skip)] + pub active_tab: Signal, + #[serde(skip)] + pub show_terminal_log: Signal, + #[serde(skip)] + pub terminal_log: Signal, + #[serde(skip)] + pub terminal_exec_type: Signal, + #[serde(skip)] + pub show_manage_reana_modal: Signal, } impl ApplicationState { diff --git a/packages/gui/src/reana_integration.rs b/packages/gui/src/reana_integration.rs index 9a7ac47b..77ae388e 100644 --- a/packages/gui/src/reana_integration.rs +++ b/packages/gui/src/reana_integration.rs @@ -1,10 +1,13 @@ -use anyhow::anyhow; use keyring::Entry; use std::path::PathBuf; -use tokio::task; use crate::components::files::Node; -use remote_execution::compatibility_adjustments; - use dioxus::prelude::WritableExt; +use dioxus::prelude::*; +use tokio::sync::mpsc::Sender; +use tokio::task::spawn_blocking; +use std::sync::Arc; +use std::path::Path; +use remote_execution::{get_saved_workflows, save_workflow_name}; +use serde_json::Value; pub fn get_reana_credentials() -> Result, keyring::Error> { let instance_entry = Entry::new("reana", "instance")?; @@ -19,49 +22,176 @@ pub fn get_reana_credentials() -> Result, keyring::Erro } } +pub fn delete_reana_credentials() -> Result<(), keyring::Error> { + let instance_entry = Entry::new("reana", "instance")?; + let token_entry = Entry::new("reana", "token")?; + // Remove stored credentials + let _ = instance_entry.delete_credential(); + let _ = token_entry.delete_credential(); + + Ok(()) +} + pub fn store_reana_credentials(instance: &str, token: &str) -> Result<(), keyring::Error> { Entry::new("reana", "instance")?.set_password(instance)?; Entry::new("reana", "token")?.set_password(token)?; Ok(()) } -pub fn normalize_inputs(workflow_json: &mut serde_json::Value, prefix: &str) -> anyhow::Result<()> { +pub fn sanitize_path(path: &str) -> String { + let path = Path::new(path.trim()); + let mut sanitized_path = PathBuf::new(); + for comp in path.components() { + match comp { + std::path::Component::ParentDir => { + sanitized_path.pop(); + } + std::path::Component::CurDir => { + } + _ => { + sanitized_path.push(comp.as_os_str()); + } + } + } + sanitized_path + .to_string_lossy() + .replace("\\", std::path::MAIN_SEPARATOR_STR) +} + + +pub fn normalize_inputs(workflow_json: &mut Value, prefix: &str) -> Result<()> { + let clean_prefix = sanitize_path(prefix); + let prefix_tail = Path::new(&clean_prefix) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); if let Some(inputs) = workflow_json.get_mut("inputs").and_then(|v| v.as_object_mut()) - && let Some(serde_json::Value::Array(dir_list)) = inputs.get_mut("directories") - { - let normalized: Vec = dir_list - .iter() - .filter_map(|v| v.as_str()) - .map(|s| { - let mut path = s.to_string(); - if path.starts_with("../") { - path = path.trim_start_matches("../").to_string(); - } - if path.starts_with(prefix) { - path = path.trim_start_matches(prefix).to_string(); - } - serde_json::Value::String(path) - }) - .collect(); - *dir_list = normalized; + && let Some(Value::Array(dir_list)) = inputs.get_mut("directories") { + let normalized: Vec = dir_list + .iter() + .filter_map(|v| v.as_str()) + .map(|s| { + let mut path = sanitize_path(s); + while path.starts_with("../") { + path = path.trim_start_matches("../").to_string(); + } + if path.starts_with(&clean_prefix) { + path = path[clean_prefix.len()..].trim_start_matches(['/', '\\']).to_string(); + } + if let Some(idx) = path.find(&prefix_tail) { + path = path[idx + prefix_tail.len()..] + .trim_start_matches(['/', '\\']) + .to_string(); + } + Value::String(path) + }) + .collect(); + *dir_list = normalized; } Ok(()) } +async fn log_msg(sender: &Option>, message: &str) { + if let Some(tx) = sender { + let _ = tx.send(format!("{message}\n")).await; + } else { + eprintln!("{message}"); + } +} + +pub async fn run_reana_async( + reana: reana::reana::Reana, + workflow_name: String, + workflow_json: serde_json::Value, + working_dir: PathBuf, + file_name: PathBuf, + log_sender: Option>, +) -> anyhow::Result<()> { + let reana = Arc::new(reana); + let creds = get_reana_credentials()?; + let instance_url = if let Some((instance, _token)) = creds { + instance + } else { + return Err(anyhow::anyhow!("No REANA credentials found")); + }; + log_msg(&log_sender, "🚀 Starting REANA workflow setup...").await; + log_msg(&log_sender, "📁 Creating workflow...").await; + + let workflow_name_str = { + let reana = reana.clone(); + let workflow_json = workflow_json.clone(); + let workflow_name = workflow_name.clone(); + spawn_blocking(move || -> anyhow::Result { + let create_response = reana::api::create_workflow(&reana, &workflow_json, Some(&workflow_name)) + .map_err(|e| anyhow::anyhow!("Create workflow failed: {e}"))?; + + let workflow_name_str = create_response["workflow_name"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing workflow_name in response"))?; + + Ok(workflow_name_str.to_string()) + }) + .await?? + }; + log_msg(&log_sender, &format!("Created workflow '{}'", workflow_name_str)).await; + save_workflow_name(&instance_url, &workflow_name_str).await + .map_err(|e| anyhow::anyhow!("Saving workflow failed: {e}"))?; + log_msg(&log_sender, "📤 Uploading input files...").await; + { + let reana = reana.clone(); + let workflow_json = workflow_json.clone(); + let workflow_name = workflow_name.clone(); + let file_name = file_name.clone(); + let working_dir = working_dir.clone(); + spawn_blocking(move || { + reana::api::upload_files( + &reana, + &None, + &file_name, + &workflow_name, + &workflow_json, + Some(&working_dir), + ) + .map_err(|e| anyhow::anyhow!("Upload files failed: {e}")) + }) + .await??; + } + let yaml: serde_yaml::Value = serde_json::from_value(workflow_json) + .map_err(|e| anyhow::anyhow!("JSON to YAML conversion failed: {e}"))?; + { + let reana = reana.clone(); + let workflow_name = workflow_name.clone(); + log_msg(&log_sender, &format!("▶️ Starting workflow execution for '{}'", workflow_name_str)).await; + let yaml = yaml.clone(); + spawn_blocking(move || { + reana::api::start_workflow(&reana, &workflow_name, None, None, false, &yaml) + .map_err(|e| anyhow::anyhow!("Start workflow failed: {e}")) + }) + .await??; + } + log_msg(&log_sender, "✅ Workflow started successfully!").await; + +Ok(()) +} + pub async fn execute_reana_workflow( item: Node, working_dir: PathBuf, - mut show_settings: dioxus::prelude::Signal, -) { + mut show_settings: Signal, + log_sender: Option>, +) -> Result<()> { + log_msg(&log_sender, "🔹 Initializing REANA execution...").await; let (instance, token) = match get_reana_credentials() { Ok(Some(creds)) => creds, Ok(None) => { + log_msg(&log_sender, "⚠️ No REANA credentials found. Opening settings...").await; show_settings.set(true); - return; + return Ok(()); } Err(e) => { - eprintln!("❌ Failed to retrieve REANA credentials: {e}"); - return; + log_msg(&log_sender, &format!("❌ Failed to get REANA credentials: {e}")).await; + return Ok(()); } }; let input_file = working_dir.join("inputs.yml"); @@ -69,53 +199,78 @@ pub async fn execute_reana_workflow( let mut workflow = match reana::parser::generate_workflow_json_from_cwl(&cwl_file, &Some(input_file)) { Ok(wf) => wf, Err(e) => { - eprintln!("❌ Failed to generate workflow JSON: {e}"); - return; + log_msg(&log_sender, &format!("❌ Failed to generate workflow JSON: {e}")).await; + return Ok(()); } }; - if let Err(e) = compatibility_adjustments(&mut workflow) { - eprintln!("❌ Compatibility adjustment failed: {e}"); - return; + if let Err(e) = remote_execution::compatibility_adjustments(&mut workflow, log_sender.clone()).await { + log_msg(&log_sender, &format!("❌ Compatibility adjustments failed: {e}")).await; + return Ok(()); } let mut workflow_value = match serde_json::to_value(&workflow) { Ok(v) => v, Err(e) => { - eprintln!("❌ Failed to serialize workflow: {e}"); - return; + log_msg(&log_sender, &format!("❌ Failed to serialize workflow: {e}")).await; + return Ok(()); } }; if let Err(e) = normalize_inputs(&mut workflow_value, working_dir.to_str().unwrap_or("")) { - eprintln!("❌ Input normalization failed: {e}"); - return; + log_msg(&log_sender, &format!("❌ Input normalization failed: {e}")).await; + return Ok(()); } - let workflow = match serde_json::from_value(workflow_value) { + let workflow: serde_json::Value = match serde_json::from_value(workflow_value) { Ok(wf) => wf, Err(e) => { - eprintln!("❌ Failed to deserialize normalized workflow: {e}"); - return; + log_msg(&log_sender, &format!("❌ Failed to deserialize normalized workflow: {e}")).await; + return Ok(()); } }; - let workflow_name = std::path::Path::new(&item.name) + let workflow_name = PathBuf::from(&item.name) .file_stem() .and_then(|s| s.to_str()) .unwrap_or(&item.name) .to_string(); let reana = reana::reana::Reana::new(instance, token); - let result = task::spawn_blocking(move || { - run_reana_blocking(reana, workflow_name, workflow, working_dir, item.path) - }) - .await; - match result { - Ok(Ok(())) => println!("✅ Workflow started successfully"), - Ok(Err(e)) => eprintln!("❌ Workflow failed: {e}"), - Err(e) => eprintln!("❌ Task join error: {e}"), - } -} + let log_sender_clone = log_sender.clone(); + let working_dir_clone = working_dir.clone(); + let item_path_clone = item.path.clone(); + let workflow_clone = workflow.clone(); + let workflow_name_clone = workflow_name.clone(); + tokio::spawn(async move { + if let Err(e) = run_reana_async( + reana, + workflow_name_clone, + workflow_clone, + working_dir_clone, + item_path_clone, + log_sender_clone, + ) + .await + { + if let Some(tx) = log_sender { + let _ = tx.send(format!("❌ Workflow execution failed: {e}\n")).await; + } else { + eprintln!("❌ Workflow execution failed: {e}"); + } + } + }); -fn run_reana_blocking(reana: reana::reana::Reana, workflow_name: String, workflow_json: serde_json::Value, working_dir: PathBuf, file_name: PathBuf) -> anyhow::Result<()> { - reana::api::create_workflow(&reana, &workflow_json, Some(&workflow_name)).map_err(|e| anyhow!("Create workflow failed: {e}"))?; - reana::api::upload_files(&reana, &None, &file_name, &workflow_name, &workflow_json, Some(&working_dir)).map_err(|e| anyhow!("Upload files failed: {e}"))?; - let yaml: serde_yaml::Value = serde_json::from_value(workflow_json).map_err(|e| anyhow!("JSON to YAML conversion failed: {e}"))?; - reana::api::start_workflow(&reana, &workflow_name, None, None, false, &yaml).map_err(|e| anyhow!("Start workflow failed: {e}"))?; Ok(()) +} + +pub async fn get_last_workflow_name() -> anyhow::Result { + let (instance, _token) = match get_reana_credentials() { + Ok(Some(creds)) => creds, + Ok(None) => { + return Ok(String::new()); + } + Err(_err) => { + return Ok(String::new()); + } + }; + let saved_workflows = get_saved_workflows(&instance); + let last_workflow = saved_workflows + .last() + .ok_or_else(|| anyhow::anyhow!("No saved workflows found for this instance"))?; + Ok(last_workflow.to_string()) } \ No newline at end of file diff --git a/packages/remote_execution/Cargo.toml b/packages/remote_execution/Cargo.toml index 1b47493f..4830fb62 100644 --- a/packages/remote_execution/Cargo.toml +++ b/packages/remote_execution/Cargo.toml @@ -20,6 +20,7 @@ serde_json.workspace = true serde_yaml.workspace = true toml.workspace = true uuid.workspace = true +tokio = {version = "1.49.0"} [lints] workspace = true diff --git a/packages/remote_execution/src/lib.rs b/packages/remote_execution/src/lib.rs index 6822b0d8..f2b799d1 100644 --- a/packages/remote_execution/src/lib.rs +++ b/packages/remote_execution/src/lib.rs @@ -2,11 +2,14 @@ use std::path::{PathBuf, Path}; use reana_ext::parser::WorkflowJson; use anyhow::Result; mod reana; +use anyhow::anyhow; +use tokio::sync::mpsc::Sender; -pub fn schedule_run(file: &Path, input_file: &Option) -> Result> { - reana::execute_remote_start(file, input_file) +pub fn schedule_run(file: &Path, input_file: &Option) -> Result { + let rt = tokio::runtime::Runtime::new() + .map_err(|e| anyhow!("Failed to create tokio runtime: {e}"))?; + rt.block_on(reana::execute_remote_start(file, input_file)) } - pub fn check_status(workflow_name: &Option) -> Result<(), Box> { reana::check_remote_status(workflow_name) } @@ -15,8 +18,8 @@ pub fn download_results(workflow_name: &str, all: bool, output_dir: Option<&Stri reana::download_remote_results(workflow_name, all, output_dir) } -pub fn export_rocrate(workflow_name: &str, output_dir: Option<&String>) -> Result<(), Box> { - reana::export_rocrate(workflow_name, output_dir) +pub fn export_rocrate(workflow_name: &str, output_dir: Option<&String>, working_dir: Option<&String>) -> Result<(), Box> { + reana::export_rocrate(workflow_name, output_dir, working_dir) } pub fn logout() -> Result<(), Box> { @@ -27,8 +30,14 @@ pub fn watch(workflow_name: &str, rocrate: bool) -> Result<(), Box Result<()> { - crate::reana::compatibility_adjustments(workflow_json) +pub async fn compatibility_adjustments(workflow_json: &mut WorkflowJson, log_sender: Option>) -> anyhow::Result<()> { + reana::compatibility_adjustments(workflow_json, log_sender).await +} + +pub async fn save_workflow_name(instance_url: &str, name: &str) -> std::io::Result<()> { + reana::save_workflow_name(instance_url, name).await } + +pub fn get_saved_workflows(instance_url: &str) -> Vec { + reana::get_saved_workflows(instance_url) +} \ No newline at end of file diff --git a/packages/remote_execution/src/reana/compatibility.rs b/packages/remote_execution/src/reana/compatibility.rs index c5a5be9e..69dda7ac 100644 --- a/packages/remote_execution/src/reana/compatibility.rs +++ b/packages/remote_execution/src/reana/compatibility.rs @@ -3,19 +3,30 @@ use commonwl::{prelude::*, requirements::WorkDirItem}; use log::{info, warn}; use reana_ext::parser::WorkflowJson; use std::collections::HashMap; -use std::process::{Command as SystemCommand, Stdio}; -use std::{env, fs, path::Path, thread}; -use util::{handle_process, is_docker_installed}; -use std::time::Duration; -use anyhow::anyhow; - +use std::{env, path::Path}; +use util::is_docker_installed; +use anyhow::{Result, anyhow}; +use tokio::sync::mpsc::Sender; +use tokio::process::Command as AsyncCommand; use s4n_core::parser::SCRIPT_EXECUTORS; -pub fn compatibility_adjustments(workflow_json: &mut WorkflowJson) -> anyhow::Result<()> { - let mut docker_jobs: Vec = Vec::new(); +pub async fn log_msg(log_sender: &Option>, message: &str) { + if let Some(tx) = log_sender { + let _ = tx.send(format!("{message}\n")).await; + } else { + eprintln!("{message}"); + } +} + +pub async fn compatibility_adjustments( + workflow_json: &mut WorkflowJson, + log_sender: Option>, +) -> Result<()> { if !is_docker_available() { - return Err(anyhow!("❌ Docker is not running or not accessible.")); + return Err(anyhow!("❌ Docker is not running or accessible")); } + log_msg(&log_sender, "🔧 Starting compatibility adjustments...").await; + let mut docker_jobs: Vec = vec![]; for item in &mut workflow_json.workflow.specification.graph { if let CWLDocument::CommandLineTool(tool) = item { adjust_basecommand(tool)?; @@ -24,51 +35,116 @@ pub fn compatibility_adjustments(workflow_json: &mut WorkflowJson) -> anyhow::Re } } } - if docker_jobs.is_empty() { - return Ok(()); - } - let handles: Vec<_> = docker_jobs - .into_iter() - .map(|mut tool| { - thread::spawn(move || -> anyhow::Result { - if !has_docker_pull(&tool) { - publish_docker_ephemeral(&mut tool)?; - if !has_docker_pull(&tool) { - inject_docker_pull(&mut tool)?; - } - } - Ok(tool) - }) - }) - .collect(); - let mut updated_tools = Vec::new(); - for handle in handles { - match handle.join() { - Ok(Ok(tool)) => updated_tools.push(tool), - Ok(Err(e)) => return Err(anyhow!("❌ Docker build failed: {e}")), - Err(_) => return Err(anyhow!("❌ Thread panicked during Docker build")), + for mut tool in docker_jobs { + if !has_docker_pull(&tool) { + publish_docker_ephemeral(&mut tool, &log_sender).await?; + if !has_docker_pull(&tool) { + inject_docker_pull(&mut tool, &log_sender).await?; + } } - } - for updated_tool in updated_tools { for item in &mut workflow_json.workflow.specification.graph { - if let CWLDocument::CommandLineTool(tool) = item && tool.id == updated_tool.id { - *tool = updated_tool.clone(); + if let CWLDocument::CommandLineTool(existing) = item && existing.id == tool.id { + *existing = tool.clone(); } } } - thread::sleep(Duration::from_secs(5)); + log_msg(&log_sender, "✅ Compatibility adjustments completed.").await; + Ok(()) +} + +pub async fn publish_docker_ephemeral( + tool: &mut CommandLineTool, + log_sender: &Option>, +) -> Result<()> { + let id = tool.id.clone().unwrap(); + if let Some(dr) = tool.get_requirement_mut::() + && let Some(dockerfile) = &mut dr.docker_file + { + log_msg(log_sender, &format!("⚠️ Tool {} depends on Dockerfile", id)).await; + if !is_docker_installed() { + log_msg(log_sender, "⚠️ Docker not installed, skipping image build.").await; + return Ok(()); + } + let image_name = uuid::Uuid::new_v4().to_string(); + let tag = format!("ttl.sh/{image_name}:1h"); + // Read docker content async + let docker_content = match dockerfile { + commonwl::Entry::Source(src) => src.clone(), + commonwl::Entry::Include(include) => tokio::fs::read_to_string(&include.include).await?, + }; + let file_path = env::temp_dir().join(&image_name); + tokio::fs::write(&file_path, docker_content).await?; + // Build Docker image asynchronously + let build = AsyncCommand::new("docker") + .arg("build") + .arg("-t").arg(&tag) + .arg("-f").arg(&file_path) + .arg(".") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn()?; + let output = build.wait_with_output().await?; + if !output.status.success() { + return Err(anyhow!("Docker build failed: {}", String::from_utf8_lossy(&output.stderr))); + } + log_msg(log_sender, &format!("✔️ Successfully built Docker image for tool {}", id)).await; + let push = AsyncCommand::new("docker") + .arg("push").arg(&tag) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn()?; + let output = push.wait_with_output().await?; + if !output.status.success() { + return Err(anyhow!("Docker push failed: {}", String::from_utf8_lossy(&output.stderr))); + } + log_msg(log_sender, &format!("✔️ Docker image was published at {tag} and is available for 1 hour in Tool {}", id)).await; + dr.docker_pull = Some(tag); + dr.docker_file = None; + dr.docker_image_id = None; + } + Ok(()) +} + +/// Inject Docker pull if tool uses a script executor +pub async fn inject_docker_pull( + tool: &mut CommandLineTool, + log_sender: &Option>, +) -> Result<()> { + let id = tool.id.clone().unwrap(); + let command_vec = match &tool.base_command { + Command::Multiple(vec) => vec.clone(), + _ => return Ok(()), + }; + + let default_images = HashMap::from([ + ("python", "python"), + ("Rscript", "r-base"), + ("node", "node"), + ]); + + if SCRIPT_EXECUTORS.contains(&&*command_vec[0]) { + warn!("Tool {} is using {} and does not use a proper container", id, command_vec[0]); + if let Some(container) = default_images.get(&&*command_vec[0]) { + tool.requirements.push(Requirement::DockerRequirement(DockerRequirement::from_pull(container))); + log_msg(log_sender, &format!("✔️ Added container {} to tool {}", container, id)).await; + } + } + Ok(()) } +/// Check if Docker is available (blocking is fine, rarely called) fn is_docker_available() -> bool { std::process::Command::new("docker") .arg("info") .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) - .status().map(|s| s.success()).unwrap_or(false) + .status() + .map(|s| s.success()) + .unwrap_or(false) } -///checks if tool has a docker pull already +/// Check if a tool already has a docker_pull requirement fn has_docker_pull(tool: &CommandLineTool) -> bool { tool.requirements.iter().any(|req| { if let Requirement::DockerRequirement(docker_req) = req { @@ -79,32 +155,29 @@ fn has_docker_pull(tool: &CommandLineTool) -> bool { }) } -/// adjusts path as a workaround for -fn adjust_basecommand(tool: &mut CommandLineTool) -> anyhow::Result<()> { +/// Adjust base command if initialWorkDirRequirement modifies scripts +fn adjust_basecommand(tool: &mut CommandLineTool) -> Result<()> { let mut changed = false; let mut command_vec = match &tool.base_command { Command::Multiple(vec) => vec.clone(), _ => return Ok(()), }; + if let Some(iwdr) = tool.get_requirement_mut::() { for item in &mut iwdr.listing { if let WorkDirItem::Dirent(dirent) = item && let Some(entryname) = &mut dirent.entryname && command_vec.contains(entryname) { - //check whether entryname has a path attached to script item and rewrite command and entryname if so let path = Path::new(entryname); if path.parent().is_some() { - let pos = command_vec - .iter() - .position(|c| c == entryname) - .ok_or(anyhow::anyhow!("Failed to find command item {entryname}"))?; - *entryname = path - .file_name() - .ok_or(anyhow::anyhow!("Failed to get filename from {path:?}"))? + let pos = command_vec.iter().position(|c| c == entryname) + .ok_or(anyhow!("Failed to find command item {entryname}"))?; + *entryname = path.file_name() + .ok_or(anyhow!("Failed to get filename from {path:?}"))? .to_string_lossy() .into_owned(); - command_vec[pos] = (*entryname).to_string(); + command_vec[pos] = entryname.clone(); changed = true; } } @@ -119,93 +192,4 @@ fn adjust_basecommand(tool: &mut CommandLineTool) -> anyhow::Result<()> { tool.base_command = Command::Multiple(command_vec); } Ok(()) -} - -/// adjusts dockerrequirement as a workaround for -fn publish_docker_ephemeral(tool: &mut CommandLineTool) -> anyhow::Result<()> { - let id = tool.id.clone().unwrap(); - if let Some(dr) = tool.get_requirement_mut::() - && let Some(dockerfile) = &mut dr.docker_file - { - warn!("Tool {id} depends on Dockerfile, which not supported by REANA!"); - if !is_docker_installed() { - return Ok(()); - } - info!("Trying to use a workaround for Dockerfile in Tool {}...", id.green().bold()); - //we build the image and send it to ttl.sh - let image_name = uuid::Uuid::new_v4().to_string(); - let tag = format!("ttl.sh/{image_name}:1h"); - //write dockerfile to temp dir - let file_content = match dockerfile { - commonwl::Entry::Source(src) => src.clone(), - commonwl::Entry::Include(include) => fs::read_to_string(include.include.clone())?, - }; - let filenname = env::temp_dir().join(&image_name); - fs::write(&filenname, file_content)?; - - //build docker file - let mut process = SystemCommand::new("docker") - .arg("build") - .arg("-t") - .arg(&tag) - .arg("-f") - .arg(filenname) - .arg(".") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; - handle_process(&mut process, 0).map_err(|e| anyhow::anyhow!("{e}"))?; - process.wait()?; - eprintln!("✔️ Successfully built Docker image in Tool {}", id.green().bold()); - - //push - let mut process = SystemCommand::new("docker") - .arg("push") - .arg(&tag) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; - handle_process(&mut process, 0).map_err(|e| anyhow::anyhow!("{e}"))?; - process.wait()?; - eprintln!( - "✔️ Docker image was published at {tag} and is available for 1 hour in Tool {}", - id.green().bold() - ); - - //set docker pull and remove dockerfile - dr.docker_pull = Some(tag); - dr.docker_file = None; - dr.docker_image_id = None; - } - Ok(()) -} - -/// check whether "python", "Rscript", ... is used and inject Docker image -/// We can not rely on the REANA server has those tools installed -fn inject_docker_pull(tool: &mut CommandLineTool) -> anyhow::Result<()> { - let id = tool.id.clone().unwrap(); - - let command_vec = match &tool.base_command { - Command::Multiple(vec) => vec.clone(), - _ => return Ok(()), - }; - - let default_images = HashMap::from([("python", "python"), ("Rscript", "r-base"), ("node", "node")]); - - if SCRIPT_EXECUTORS.contains(&&*command_vec[0]) { - //is script executor but does not use containerization - warn!( - "Tool {} is using {} and does not use a proper container", - id.green().bold(), - command_vec[0].bold() - ); - if let Some(container) = default_images.get(&&*command_vec[0]) { - tool.requirements - .push(Requirement::DockerRequirement(DockerRequirement::from_pull(container))); - - eprintln!("✔️ Added container {} to tool {}", container.bold(), id.green().bold()); - } - } - - Ok(()) -} +} \ No newline at end of file diff --git a/packages/remote_execution/src/reana/mod.rs b/packages/remote_execution/src/reana/mod.rs index 2625be7c..8180a3df 100644 --- a/packages/remote_execution/src/reana/mod.rs +++ b/packages/remote_execution/src/reana/mod.rs @@ -10,5 +10,5 @@ pub use download::download_remote_results; pub use rocrate::export_rocrate; pub use status::check_remote_status; pub use status::watch; -pub use workflow::execute_remote_start; +pub use workflow::{execute_remote_start, save_workflow_name, get_saved_workflows}; pub use compatibility::compatibility_adjustments; diff --git a/packages/remote_execution/src/reana/rocrate.rs b/packages/remote_execution/src/reana/rocrate.rs index 4446213a..03d6bb06 100644 --- a/packages/remote_execution/src/reana/rocrate.rs +++ b/packages/remote_execution/src/reana/rocrate.rs @@ -6,7 +6,7 @@ use reana_ext::{ }; use std::{error::Error, fs, path::PathBuf}; -pub fn export_rocrate(workflow_name: &str, ro_crate_dir: Option<&String>) -> Result<(), Box> { +pub fn export_rocrate(workflow_name: &str, ro_crate_dir: Option<&String>, working_dir: Option<&String>) -> Result<(), Box> { let (reana_instance, reana_token) = login_reana()?; let reana = Reana::new(reana_instance.clone(), reana_token.clone()); @@ -15,7 +15,11 @@ pub fn export_rocrate(workflow_name: &str, ro_crate_dir: Option<&String>) -> Res match workflow_status.as_str() { "finished" => { let workflow_json = get_workflow_specification(&reana, workflow_name)?; - let config_path = PathBuf::from("workflow.toml"); + let config_path = if let Some(working_dir) = working_dir { + PathBuf::from(working_dir).join("workflow.toml") + } else { + PathBuf::from("workflow.toml") + }; let config_str = fs::read_to_string(&config_path)?; let specification = workflow_json .get("specification") diff --git a/packages/remote_execution/src/reana/status.rs b/packages/remote_execution/src/reana/status.rs index b9c830d1..7a498a44 100644 --- a/packages/remote_execution/src/reana/status.rs +++ b/packages/remote_execution/src/reana/status.rs @@ -66,7 +66,7 @@ pub fn watch(workflow_name: &str, rocrate: bool) -> Result<(), Box> { if let Err(e) = crate::reana::download_remote_results(workflow_name, false, None) { eprintln!("Error downloading remote results: {e}"); } - if rocrate && let Err(e) = export_rocrate(workflow_name, Some(&"rocrate".to_string())) { + if rocrate && let Err(e) = export_rocrate(workflow_name, Some(&"rocrate".to_string()), None) { eprintln!("Error trying to create a Provenance RO-Crate: {e}"); } } diff --git a/packages/remote_execution/src/reana/workflow.rs b/packages/remote_execution/src/reana/workflow.rs index d23909aa..6de13dc0 100644 --- a/packages/remote_execution/src/reana/workflow.rs +++ b/packages/remote_execution/src/reana/workflow.rs @@ -1,53 +1,74 @@ -use crate::reana::{auth::login_reana, compatibility::compatibility_adjustments, status::status_file_path}; +use crate::reana::{auth::login_reana, compatibility::{compatibility_adjustments}, status::status_file_path}; use reana_ext::{ api::{create_workflow, ping_reana, upload_files, start_workflow}, parser::generate_workflow_json_from_cwl, reana::Reana, }; use s4n_core::config; -use std::{collections::HashMap, error::Error, fs, path::{PathBuf, Path}}; +use std::{collections::HashMap, fs, path::{PathBuf, Path}}; +use anyhow::{Result, anyhow}; +use std::env; -pub fn execute_remote_start(file: &Path, input_file: &Option) -> Result> { +pub async fn execute_remote_start(file: &Path, input_file: &Option) -> Result { let config_path = PathBuf::from("workflow.toml"); let config: Option = if config_path.exists() { - Some(toml::from_str(&fs::read_to_string(&config_path)?)?) + let contents = std::fs::read_to_string(&config_path)?; + Some(toml::from_str(&contents)?) } else { None }; let workflow_name = derive_workflow_name(file, config.as_ref()); // Get credentials - let (reana_instance, reana_token) = login_reana()?; + let (reana_instance, reana_token) = login_reana() + .map_err(|e| anyhow!("Failed to login to REANA: {e}"))?; let reana = Reana::new(reana_instance.clone(), reana_token.clone()); - // Ping - let ping_status = ping_reana(&reana)?; + // Ping server + let ping_status = ping_reana(&reana) + .map_err(|e| anyhow!("Failed to ping REANA server: {e}"))?; if ping_status.get("status").and_then(|s| s.as_str()) != Some("200") { - return Err(format!("⚠️ Unexpected response from Reana server: {ping_status:?}").into()); + return Err(anyhow!("⚠️ Unexpected response from REANA server: {ping_status:?}")); } - // Generate worfklow.json - let mut workflow_json = generate_workflow_json_from_cwl(file, input_file)?; - if let Err(e) = compatibility_adjustments(&mut workflow_json) { - eprintln!("❌ Compatibility adjustment failed: {e}"); - std::process::exit(1); - } + let mut workflow_json = generate_workflow_json_from_cwl(file, input_file) + .map_err(|e| anyhow!("Failed to generate workflow JSON: {e}"))?; - let workflow_json = serde_json::to_value(workflow_json)?; - let converted_yaml: serde_yaml::Value = serde_json::from_value(workflow_json.clone())?; - // Create workflow - let create_response = create_workflow(&reana, &workflow_json, Some(&workflow_name))?; - let Some(workflow_name) = create_response["workflow_name"].as_str() else { - return Err("Missing workflow_name in response".into()); - }; - let working_dir = std::env::current_dir()?; - upload_files(&reana, input_file, file, workflow_name, &workflow_json, Some(&working_dir))?; - start_workflow(&reana, workflow_name, None, None, false, &converted_yaml)?; - eprintln!("✅ Started workflow execution of '{workflow_name}'."); - eprintln!("You can check its status using: s4n execute remote status '{workflow_name}' or use 's4n execute remote status' to check all workflows."); - - save_workflow_name(&reana_instance, workflow_name)?; - Ok(workflow_name.to_owned()) + compatibility_adjustments(&mut workflow_json, None).await + .map_err(|e| anyhow!("❌ Compatibility adjustment failed: {e}"))?; + + let workflow_json_value = serde_json::to_value(&workflow_json) + .map_err(|e| anyhow!("Failed to convert workflow to JSON value: {e}"))?; + let converted_yaml: serde_yaml::Value = serde_json::from_value(workflow_json_value.clone()) + .map_err(|e| anyhow!("Failed to convert JSON to YAML: {e}"))?; + + let workflow_name_clone = workflow_name.clone(); + let create_response = create_workflow(&reana, &workflow_json_value, Some(&workflow_name_clone)) + .map_err(|e| anyhow!("Failed to create workflow: {e}"))?; + let workflow_name_str = create_response["workflow_name"] + .as_str() + .ok_or_else(|| anyhow!("Missing workflow_name in response"))?; + + let working_dir = env::current_dir() + .map_err(|e| anyhow!("Failed to get current directory: {e}"))?; + + // Upload files + upload_files(&reana, input_file, file, workflow_name_str, &workflow_json_value, Some(&working_dir)) + .map_err(|e| anyhow!("Failed to upload files: {e}"))?; + + // Start workflow + start_workflow(&reana, workflow_name_str, None, None, false, &converted_yaml) + .map_err(|e| anyhow!("Failed to start workflow: {e}"))?; + + eprintln!("✅ Started workflow execution of '{workflow_name_str}'."); + eprintln!("You can check its status using: s4n execute remote status '{workflow_name_str}' or use 's4n execute remote status' to check all workflows."); + + // Save workflow name + save_workflow_name(&reana_instance, workflow_name_str) + .await + .map_err(|e| anyhow!("Failed to save workflow name: {e}"))?; + + Ok(workflow_name_str.to_owned()) } pub fn analyze_workflow_logs(logs_str: &str) { @@ -81,7 +102,7 @@ pub fn analyze_workflow_logs(logs_str: &str) { } } -fn save_workflow_name(instance_url: &str, name: &str) -> std::io::Result<()> { +pub async fn save_workflow_name(instance_url: &str, name: &str) -> std::io::Result<()> { let file_path = status_file_path(); let mut workflows: HashMap> = if file_path.exists() { let content = fs::read_to_string(&file_path)?; @@ -97,7 +118,7 @@ fn save_workflow_name(instance_url: &str, name: &str) -> std::io::Result<()> { Ok(()) } -pub(super) fn get_saved_workflows(instance_url: &str) -> Vec { +pub fn get_saved_workflows(instance_url: &str) -> Vec { let file_path = status_file_path(); if !file_path.exists() { return vec![]; From ddb06e0f08641d70f2bb7387cc90930a377c1719 Mon Sep 17 00:00:00 2001 From: aleidel Date: Mon, 2 Mar 2026 22:37:14 +0100 Subject: [PATCH 13/16] improved execution --- packages/remote_execution/src/reana/compatibility.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/remote_execution/src/reana/compatibility.rs b/packages/remote_execution/src/reana/compatibility.rs index 69dda7ac..96e5a61d 100644 --- a/packages/remote_execution/src/reana/compatibility.rs +++ b/packages/remote_execution/src/reana/compatibility.rs @@ -67,14 +67,12 @@ pub async fn publish_docker_ephemeral( } let image_name = uuid::Uuid::new_v4().to_string(); let tag = format!("ttl.sh/{image_name}:1h"); - // Read docker content async let docker_content = match dockerfile { commonwl::Entry::Source(src) => src.clone(), commonwl::Entry::Include(include) => tokio::fs::read_to_string(&include.include).await?, }; let file_path = env::temp_dir().join(&image_name); tokio::fs::write(&file_path, docker_content).await?; - // Build Docker image asynchronously let build = AsyncCommand::new("docker") .arg("build") .arg("-t").arg(&tag) @@ -105,7 +103,6 @@ pub async fn publish_docker_ephemeral( Ok(()) } -/// Inject Docker pull if tool uses a script executor pub async fn inject_docker_pull( tool: &mut CommandLineTool, log_sender: &Option>, @@ -133,7 +130,6 @@ pub async fn inject_docker_pull( Ok(()) } -/// Check if Docker is available (blocking is fine, rarely called) fn is_docker_available() -> bool { std::process::Command::new("docker") .arg("info") @@ -144,7 +140,6 @@ fn is_docker_available() -> bool { .unwrap_or(false) } -/// Check if a tool already has a docker_pull requirement fn has_docker_pull(tool: &CommandLineTool) -> bool { tool.requirements.iter().any(|req| { if let Requirement::DockerRequirement(docker_req) = req { @@ -155,7 +150,6 @@ fn has_docker_pull(tool: &CommandLineTool) -> bool { }) } -/// Adjust base command if initialWorkDirRequirement modifies scripts fn adjust_basecommand(tool: &mut CommandLineTool) -> Result<()> { let mut changed = false; let mut command_vec = match &tool.base_command { From 8e89765e45c73d5e68a19799836d28fffb462ff4 Mon Sep 17 00:00:00 2001 From: aleidel Date: Mon, 2 Mar 2026 22:38:06 +0100 Subject: [PATCH 14/16] minor change --- packages/cli/src/commands/execute.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/execute.rs b/packages/cli/src/commands/execute.rs index 64b73ee1..2e7b5c84 100644 --- a/packages/cli/src/commands/execute.rs +++ b/packages/cli/src/commands/execute.rs @@ -19,7 +19,7 @@ pub fn handle_execute_commands(subcommand: &ExecuteCommands) -> Result<(), Box schedule_run(file, input_file, *rocrate, *watch, *logout), RemoteSubcommands::Status { workflow_name } => check_status(workflow_name), RemoteSubcommands::Download { workflow_name, all, output_dir } => download_results(workflow_name, *all, output_dir.as_ref()), - RemoteSubcommands::Rocrate { workflow_name, output_dir } => export_rocrate(workflow_name, output_dir.as_ref()), + RemoteSubcommands::Rocrate { workflow_name, output_dir } => export_rocrate(workflow_name, output_dir.as_ref(), None), RemoteSubcommands::Logout => logout(), }, ExecuteCommands::MakeTemplate(args) => make_template(&args.cwl), From 244ff005582bb5f5f9d487f3ee179b25993e7c55 Mon Sep 17 00:00:00 2001 From: aleidel Date: Tue, 3 Mar 2026 09:20:36 +0100 Subject: [PATCH 15/16] changes --- packages/gui/src/components/files/solution.rs | 45 ++++++++++++++----- packages/remote_execution/Cargo.toml | 1 + .../src/reana/compatibility.rs | 15 +++++-- 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/packages/gui/src/components/files/solution.rs b/packages/gui/src/components/files/solution.rs index 4341d3aa..349c16cc 100644 --- a/packages/gui/src/components/files/solution.rs +++ b/packages/gui/src/components/files/solution.rs @@ -152,23 +152,19 @@ pub fn SolutionView(project_path: ReadSignal, dialog_signals: (Signal path, + Err(msg) => { + let _ = tx.blocking_send(format!("{msg}\n")); + return; + } + }; let inputs_file = dir.join("inputs.yml"); - if !cwl_path.exists() { - let _ = tx.blocking_send(format!("❌ CWL file not found: {:?}\n", cwl_path)); - return; - } if !inputs_file.exists() { let _ = tx.blocking_send(format!("❌ inputs.yml not found: {:?}\n", inputs_file)); return; } - let _ = tx.blocking_send("⚙️ Starting CWL execution...\n".to_string()); let result = execute_cwlfile(&cwl_path, &args, Some(dir)); let _ = match result { Ok(_) => tx.blocking_send("✅ Local execution completed.\n".to_string()), @@ -375,4 +371,29 @@ pub fn Submodule_View(module: String, files: Vec, dialog_signals: (Signal< } } } +} + +pub fn resolve_safe_cwl_path(base_dir: &Path, candidate: &Path) -> Result { + let base = base_dir + .canonicalize() + .map_err(|e| format!("Failed to canonicalize base directory {:?}: {e}", base_dir))?; + let joined = if candidate.is_absolute() { + candidate.to_path_buf() + } else { + base.join(candidate) + }; + let resolved = joined + .canonicalize() + .map_err(|e| format!("Failed to canonicalize CWL path {:?}: {e}", candidate))?; + if !resolved.starts_with(&base) { + return Err(format!( + "❌ Unsafe CWL path: {:?} is outside the working directory {:?}", + resolved, base + )); + } + if !resolved.exists() { + return Err(format!("❌ CWL file not found: {:?}", resolved)); + } + + Ok(resolved) } \ No newline at end of file diff --git a/packages/remote_execution/Cargo.toml b/packages/remote_execution/Cargo.toml index 4830fb62..b703fa18 100644 --- a/packages/remote_execution/Cargo.toml +++ b/packages/remote_execution/Cargo.toml @@ -20,6 +20,7 @@ serde_json.workspace = true serde_yaml.workspace = true toml.workspace = true uuid.workspace = true +tempfile.workspace = true tokio = {version = "1.49.0"} [lints] diff --git a/packages/remote_execution/src/reana/compatibility.rs b/packages/remote_execution/src/reana/compatibility.rs index 96e5a61d..d0417b72 100644 --- a/packages/remote_execution/src/reana/compatibility.rs +++ b/packages/remote_execution/src/reana/compatibility.rs @@ -3,12 +3,14 @@ use commonwl::{prelude::*, requirements::WorkDirItem}; use log::{info, warn}; use reana_ext::parser::WorkflowJson; use std::collections::HashMap; -use std::{env, path::Path}; +use std::path::Path; use util::is_docker_installed; use anyhow::{Result, anyhow}; use tokio::sync::mpsc::Sender; use tokio::process::Command as AsyncCommand; use s4n_core::parser::SCRIPT_EXECUTORS; +use tempfile::NamedTempFile; +use std::io::Write; pub async fn log_msg(log_sender: &Option>, message: &str) { if let Some(tx) = log_sender { @@ -71,8 +73,14 @@ pub async fn publish_docker_ephemeral( commonwl::Entry::Source(src) => src.clone(), commonwl::Entry::Include(include) => tokio::fs::read_to_string(&include.include).await?, }; - let file_path = env::temp_dir().join(&image_name); - tokio::fs::write(&file_path, docker_content).await?; + let mut temp_file = NamedTempFile::new() + .map_err(|e| anyhow!("Failed to create temporary file: {e}"))?; + + temp_file + .write_all(docker_content.as_bytes()) + .map_err(|e| anyhow!("Failed to write temporary Dockerfile: {e}"))?; + + let file_path = temp_file.into_temp_path(); let build = AsyncCommand::new("docker") .arg("build") .arg("-t").arg(&tag) @@ -102,7 +110,6 @@ pub async fn publish_docker_ephemeral( } Ok(()) } - pub async fn inject_docker_pull( tool: &mut CommandLineTool, log_sender: &Option>, From 04f01591df3d91be2f2bb5cbab8abf6109f9a0ff Mon Sep 17 00:00:00 2001 From: aleidel Date: Tue, 3 Mar 2026 10:58:28 +0100 Subject: [PATCH 16/16] missing tab.rs --- packages/gui/src/components/layout/tabs.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/gui/src/components/layout/tabs.rs b/packages/gui/src/components/layout/tabs.rs index 9cc2970e..0b6a50aa 100644 --- a/packages/gui/src/components/layout/tabs.rs +++ b/packages/gui/src/components/layout/tabs.rs @@ -1,5 +1,6 @@ use dioxus::prelude::*; use dioxus_primitives::tabs::{self, TabContentProps, TabListProps, TabTriggerProps}; +use crate::use_app_state; #[derive(Props, Clone, PartialEq)] pub struct TabsProps { @@ -14,10 +15,22 @@ pub struct TabsProps { #[component] pub fn Tabs(props: TabsProps) -> Element { + let mut app_state = use_app_state(); + let active_tab = use_signal(|| app_state.read().active_tab.to_string()); + { + let mut active_tab = active_tab; + let app_state = app_state; + use_effect(move || { + active_tab.set(app_state.read().active_tab.to_string()); + }); + } rsx! { tabs::Tabs { - class: props.class + " select-none grid h-full w-full grid-rows-[auto_1fr]", - default_value: props.default_value, + class: format!("{} select-none grid h-full w-full grid-rows-[auto_1fr]", props.class), + value: active_tab(), + on_value_change: move |new_value: String| { + app_state.write().active_tab.set(new_value); + }, {props.children} } } @@ -49,7 +62,7 @@ pub fn TabTrigger(props: TabTriggerProps) -> Element { pub fn TabContent(props: TabContentProps) -> Element { rsx! { tabs::TabContent { - class: props.class.unwrap_or_default() + " p-1 border-1 border-zinc-400 bg-white", + class: format!("{} p-1 border-1 border-zinc-400 bg-white", props.class.clone().unwrap_or_default()), value: props.value, id: props.id, index: props.index,