diff --git a/Cargo.lock b/Cargo.lock index 7ecb39ab9..ebc71f315 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4921,19 +4921,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.11.0", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "sha1" version = "0.10.6" @@ -5219,7 +5206,6 @@ dependencies = [ "rmcp 0.9.1", "serde", "serde_json", - "serde_yaml", "terminator-mcp-agent", "tokio", "tokio-cron-scheduler", @@ -5273,7 +5259,6 @@ dependencies = [ "sentry-tracing", "serde", "serde_json", - "serde_yaml", "sysinfo 0.33.1", "tempfile", "terminator-rs", @@ -6044,12 +6029,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.9.0" diff --git a/crates/terminator-cli/Cargo.toml b/crates/terminator-cli/Cargo.toml index 9f1e58526..6c7fe536e 100644 --- a/crates/terminator-cli/Cargo.toml +++ b/crates/terminator-cli/Cargo.toml @@ -23,7 +23,6 @@ path = "src/bin/cargo-terminator.rs" [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.140" -serde_yaml = "0.9" clap = { version = "4.4", features = ["derive", "env"] } tokio = { version = "1", features = [ "rt", diff --git a/crates/terminator-cli/src/main.rs b/crates/terminator-cli/src/main.rs index e53a62a75..7304fbaab 100644 --- a/crates/terminator-cli/src/main.rs +++ b/crates/terminator-cli/src/main.rs @@ -116,7 +116,7 @@ struct McpRunArgs { #[clap(long, short = 'c', conflicts_with = "url")] command: Option, - /// Input source - can be a GitHub gist URL, raw gist URL, or local file path (JSON/YAML) + /// Input source - can be a GitHub gist URL, raw gist URL, or local file path (JSON) input: String, /// Input type (auto-detected by default) @@ -1491,7 +1491,7 @@ async fn run_workflow(transport: mcp_client::Transport, args: McpRunArgs) -> any return Ok(()); } - // Fetch workflow content (for YAML workflows) + // Fetch workflow content (for workflows) let content = match resolved_type { InputType::File => { info!("Reading local file"); @@ -1711,7 +1711,7 @@ async fn run_workflow(transport: mcp_client::Transport, args: McpRunArgs) -> any Ok(()) } -/// Extract cron expression from workflow YAML +/// Extract cron expression from workflow fn extract_cron_from_workflow(workflow: &Value) -> Option { // Primary format: cron field at root level (simpler format) if let Some(cron) = workflow.get("cron") { @@ -1914,7 +1914,7 @@ async fn run_workflow_once( return Ok(()); } - // Fetch workflow content (for YAML workflows) + // Fetch workflow content (for workflows) let content = match resolved_type { InputType::File => read_local_file(&args.input).await?, InputType::Gist => { @@ -2164,8 +2164,8 @@ fn parse_workflow_content(content: &str) -> anyhow::Result { } } - // Strategy 2: Try direct YAML workflow - if let Ok(val) = serde_yaml::from_str::(content) { + // Strategy 2: Try as wrapper object + if let Ok(val) = serde_json::from_str::(content) { // Check if it's a valid workflow (has steps field) if val.get("steps").is_some() { return Ok(val); @@ -2184,18 +2184,12 @@ fn parse_workflow_content(content: &str) -> anyhow::Result { } } - // Strategy 4: Try parsing as YAML wrapper first, then extract - if let Ok(val) = serde_yaml::from_str::(content) { - if let Some(extracted) = extract_workflow_from_wrapper(&val)? { - return Ok(extracted); - } - } Err(anyhow::anyhow!( - "Unable to parse content as JSON or YAML workflow or wrapper object. Content must either be:\n\ + "Unable to parse content as JSON workflow or wrapper object. Content must either be:\n\ 1. A workflow with 'steps' field\n\ 2. A wrapper object with tool_name='execute_sequence' and 'arguments' field\n\ - 3. Valid JSON or YAML format" + 3. Valid JSON format" )) } diff --git a/crates/terminator-cli/src/mcp_client.rs b/crates/terminator-cli/src/mcp_client.rs index 04e1b53d3..edde90dc2 100644 --- a/crates/terminator-cli/src/mcp_client.rs +++ b/crates/terminator-cli/src/mcp_client.rs @@ -1107,7 +1107,7 @@ pub async fn execute_command_with_progress_and_retry( Err(e) => { let error_str = e.to_string(); // TypeScript workflows: don't retry on timeout (should handle retries internally) - // YAML workflows: retry on timeout + // Workflows: retry on timeout // Other tools: retry on timeout let is_retryable = if is_typescript_workflow { // TypeScript workflows should handle retries internally @@ -1118,7 +1118,7 @@ pub async fn execute_command_with_progress_and_retry( || error_str.contains("503") || error_str.contains("504") } else { - // YAML workflows and other tools can retry on timeout + // Workflows can retry on timeout error_str.contains("401") || error_str.contains("Unauthorized") || error_str.contains("500") @@ -1271,7 +1271,7 @@ pub async fn execute_command_with_progress_and_retry( Err(e) => { let error_str = e.to_string(); // TypeScript workflows: don't retry on timeout (should handle retries internally) - // YAML workflows: retry on timeout + // Workflows: retry on timeout // Other tools: retry on timeout let is_retryable = if is_typescript_workflow { // TypeScript workflows should handle retries internally @@ -1282,7 +1282,7 @@ pub async fn execute_command_with_progress_and_retry( || error_str.contains("503") || error_str.contains("504") } else { - // YAML workflows and other tools can retry on timeout + // Workflows can retry on timeout error_str.contains("401") || error_str.contains("Unauthorized") || error_str.contains("500") @@ -1370,23 +1370,6 @@ mod tests { assert!(is_js, "Should detect .js file as JavaScript workflow"); - // Test YAML workflow detection (URL ends with .yml or .yaml) - let args_yaml = serde_json::json!({ - "url": "file:///path/to/workflow.yml" - }); - let args_map_yaml = args_yaml.as_object().cloned(); - - let is_yaml = args_map_yaml - .as_ref() - .and_then(|args| args.get("url")) - .and_then(|url| url.as_str()) - .map(|url| url.ends_with(".ts") || url.ends_with(".js")) - .unwrap_or(false); - - assert!( - !is_yaml, - "Should NOT detect .yml file as TypeScript workflow" - ); // Test no URL provided let args_no_url = serde_json::json!({ @@ -1419,7 +1402,7 @@ mod tests { || error_str.contains("503") || error_str.contains("504") } else { - // YAML workflows and other tools can retry on timeout + // Workflows can retry on timeout error_str.contains("401") || error_str.contains("Unauthorized") || error_str.contains("500") @@ -1460,57 +1443,4 @@ mod tests { ); } - #[test] - fn test_retry_logic_for_yaml_workflows() { - // Test that timeout errors ARE retryable for YAML workflows - let error_str = "timeout waiting for element"; // lowercase to match contains() check - let is_typescript_workflow = false; - - let is_retryable = if is_typescript_workflow { - error_str.contains("401") - || error_str.contains("Unauthorized") - || error_str.contains("500") - || error_str.contains("502") - || error_str.contains("503") - || error_str.contains("504") - } else { - error_str.contains("401") - || error_str.contains("Unauthorized") - || error_str.contains("500") - || error_str.contains("502") - || error_str.contains("503") - || error_str.contains("504") - || error_str.contains("timeout") - }; - - assert!( - is_retryable, - "YAML workflows SHOULD retry on timeout errors" - ); - - // Test that HTTP errors ARE retryable for YAML workflows - let error_str_502 = "502 Bad Gateway"; - - let is_retryable_502 = if is_typescript_workflow { - error_str_502.contains("401") - || error_str_502.contains("Unauthorized") - || error_str_502.contains("500") - || error_str_502.contains("502") - || error_str_502.contains("503") - || error_str_502.contains("504") - } else { - error_str_502.contains("401") - || error_str_502.contains("Unauthorized") - || error_str_502.contains("500") - || error_str_502.contains("502") - || error_str_502.contains("503") - || error_str_502.contains("504") - || error_str_502.contains("timeout") - }; - - assert!( - is_retryable_502, - "YAML workflows SHOULD retry on HTTP 502 errors" - ); - } } diff --git a/crates/terminator-mcp-agent/Cargo.toml b/crates/terminator-mcp-agent/Cargo.toml index 57922296c..cff2d499b 100644 --- a/crates/terminator-mcp-agent/Cargo.toml +++ b/crates/terminator-mcp-agent/Cargo.toml @@ -54,7 +54,6 @@ hostname = "0.4" reqwest = { version = "0.12.5", features = ["json", "blocking"] } # YAML parsing support -serde_yaml = "0.9" # File search support for search_terminator_api tools glob = "0.3" diff --git a/crates/terminator-mcp-agent/src/lib.rs b/crates/terminator-mcp-agent/src/lib.rs index 5a583f4c0..677071e8d 100644 --- a/crates/terminator-mcp-agent/src/lib.rs +++ b/crates/terminator-mcp-agent/src/lib.rs @@ -6,7 +6,7 @@ pub mod expression_eval; pub mod helpers; pub mod mcp_types; pub mod omniparser; -pub mod output_parser; + pub mod prompt; pub mod scripting_engine; pub mod sentry; @@ -17,7 +17,7 @@ pub mod tool_logging; pub mod tree_formatter; pub mod utils; pub mod vision; -pub mod workflow_format; + pub mod workflow_typescript; // Re-export ui_tree_diff from terminator crate (single source of truth) diff --git a/crates/terminator-mcp-agent/src/output_parser.rs b/crates/terminator-mcp-agent/src/output_parser.rs deleted file mode 100644 index c7e0aedb2..000000000 --- a/crates/terminator-mcp-agent/src/output_parser.rs +++ /dev/null @@ -1,471 +0,0 @@ -use crate::scripting_engine::execute_javascript_with_nodejs; -use anyhow::Result; -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -/// JavaScript-based parser definition -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct OutputParserDefinition { - /// Optional: Specify which step ID contains the UI tree to parse - #[serde(default)] - pub ui_tree_source_step_id: Option, - /// JavaScript code that processes the tree and returns results - /// The code receives a 'tree' variable containing the UI tree - /// and should return an array of objects. - /// Either this or javascript_file_path must be provided. - pub javascript_code: Option, - /// Path to a JavaScript file containing the parser code. - /// Either this or javascript_code must be provided. - pub javascript_file_path: Option, - /// Simplified alias for 'javascript_code' - inspired by GitHub Actions syntax - /// Use this for inline JavaScript code instead of javascript_code - pub run: Option, -} - -/// The main entry point for parsing tool output. -pub async fn run_output_parser( - parser_def_val: &Value, - tool_output: &Value, -) -> Result> { - // Support simplified format where output is just a string (JavaScript code) - let parser_def = if parser_def_val.is_string() { - OutputParserDefinition { - ui_tree_source_step_id: None, - javascript_code: parser_def_val.as_str().map(|s| s.to_string()), - javascript_file_path: None, - run: None, - } - } else { - serde_json::from_value(parser_def_val.clone()).map_err(|e| { - anyhow::anyhow!( - "Invalid parser definition format. Expected JavaScript format: {}", - e - ) - })? - }; - - // Determine the JavaScript source - support 'run' as alias for 'javascript_code' - let javascript_code = parser_def.javascript_code.or(parser_def.run); - - let user_javascript_code = match (javascript_code, parser_def.javascript_file_path) { - (Some(code), None) => { - // Inline JavaScript provided (via javascript_code or run) - code - } - (None, Some(file_path)) => { - // File path provided - read the file - std::fs::read_to_string(&file_path).map_err(|e| { - anyhow::anyhow!("Failed to read JavaScript file '{}': {}", file_path, e) - })? - } - (Some(_), Some(_)) => { - return Err(anyhow::anyhow!( - "Cannot provide both inline JavaScript code ('javascript_code' or 'run') and 'javascript_file_path'. Please provide only one." - )); - } - (None, None) => { - return Err(anyhow::anyhow!( - "Must provide either 'javascript_code'/'run' (inline JavaScript) or 'javascript_file_path' (path to JavaScript file)." - )); - } - }; - - let ui_tree = - find_ui_tree_in_results(tool_output, parser_def.ui_tree_source_step_id.as_deref())?; - - // Create JavaScript code that injects available data and executes the user code - let full_script = match ui_tree { - Some(tree) => { - // UI tree parsing mode - inject both tree and full results - format!( - r#" - // Inject the UI tree as the primary variable for backward compatibility - const tree = {}; - - // Also inject the full tool output for advanced use cases - const sequenceResult = {}; - - // Execute the user's parsing logic and return the result - {} - "#, - serde_json::to_string(&tree) - .map_err(|e| anyhow::anyhow!("Failed to serialize tree: {}", e))?, - serde_json::to_string(tool_output) - .map_err(|e| anyhow::anyhow!("Failed to serialize tool output: {}", e))?, - user_javascript_code - ) - } - None => { - // No UI tree found - API/general result parsing mode - // Inject the full tool output as sequenceResult for JavaScript to process - format!( - r#" - // No UI tree available - this is likely an API-based workflow - const tree = null; - - // Inject the full tool output for result parsing - const sequenceResult = {}; - - // Execute the user's parsing logic and return the result - {} - "#, - serde_json::to_string(tool_output) - .map_err(|e| anyhow::anyhow!("Failed to serialize tool output: {}", e))?, - user_javascript_code - ) - } - }; - - // Execute JavaScript code asynchronously - let result = execute_javascript_with_nodejs(full_script, None, None, None, None, None) - .await - .map_err(|e| anyhow::anyhow!("JavaScript execution failed: {}", e))?; - - Ok(Some(result)) -} - -/// Finds a UI tree in the tool output results -fn find_ui_tree_in_results(tool_output: &Value, step_id: Option<&str>) -> Result> { - // Strategy 0: If step_id is specified, prefer UI tree from that specific step, but gracefully - // fall back to any available UI tree if that step exists without a tree or is not present. - if let Some(target_step_id) = step_id { - if let Some(results) = tool_output.get("results").and_then(|v| v.as_array()) { - // Recursive search that also records whether the step was seen at all - fn search_for_step_id( - results: &[Value], - target_step_id: &str, - found_step: &mut bool, - ) -> Option { - for result in results { - if let Some(result_step_id) = result.get("step_id").and_then(|v| v.as_str()) { - if result_step_id == target_step_id { - *found_step = true; - if let Some(ui_tree) = result.get("ui_tree") { - return Some(ui_tree.clone()); - } - if let Some(result_obj) = result.get("result") { - if let Some(ui_tree) = result_obj.get("ui_tree") { - return Some(ui_tree.clone()); - } - if let Some(content) = - result_obj.get("content").and_then(|c| c.as_array()) - { - for content_item in content { - if let Some(ui_tree) = content_item.get("ui_tree") { - return Some(ui_tree.clone()); - } - // Legacy path where JSON was embedded as text - if let Some(text) = - content_item.get("text").and_then(|t| t.as_str()) - { - if let Ok(parsed_json) = - serde_json::from_str::(text) - { - if let Some(ui_tree) = parsed_json.get("ui_tree") { - return Some(ui_tree.clone()); - } - } - } - } - } - } - // Step found but contains no ui_tree - return None; - } - } - // Search inside group results (nested) - if let Some(group_results) = result.get("results").and_then(|r| r.as_array()) { - if let Some(found) = - search_for_step_id(group_results, target_step_id, found_step) - { - return Some(found); - } - } - } - None - } - - let mut found_step = false; - if let Some(ui_tree) = search_for_step_id(results, target_step_id, &mut found_step) { - return Ok(Some(ui_tree)); - } - - // If we reached here, either the step exists without a ui_tree or it wasn't found. - // Be forgiving: fall back to general search instead of erroring out. - // This avoids breaking workflows where the referenced step is a close/minimize step. - } // else: no results array; fall through to general search - } - - // Strategy 1: Check if there's a direct ui_tree field - if let Some(ui_tree) = tool_output.get("ui_tree") { - return Ok(Some(ui_tree.clone())); - } - - // Strategy 2: Look through results array for UI trees (fallback behavior) - if let Some(results) = tool_output.get("results") { - if let Some(results_array) = results.as_array() { - for result in results_array.iter().rev() { - if let Some(ui_tree) = result.get("ui_tree") { - return Ok(Some(ui_tree.clone())); - } - - if let Some(result_obj) = result.get("result") { - if let Some(ui_tree) = result_obj.get("ui_tree") { - return Ok(Some(ui_tree.clone())); - } - - if let Some(content) = result_obj.get("content") { - if let Some(content_array) = content.as_array() { - for content_item in content_array.iter().rev() { - if let Some(text) = content_item.get("text") { - if let Some(text_str) = text.as_str() { - if let Ok(parsed_json) = - serde_json::from_str::(text_str) - { - if let Some(ui_tree) = parsed_json.get("ui_tree") { - return Ok(Some(ui_tree.clone())); - } - } - } - } - } - } - } - } - } - } - } - - Ok(None) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn test_javascript_parser() { - json!({ - "children": [ - { - "attributes": { - "role": "CheckBox", - "name": "Test Product", - "id": "123", - "is_toggled": true - } - }, - { - "attributes": { - "role": "CheckBox", - "name": "Another Product", - "id": "456", - "is_toggled": false - } - } - ] - }); - - let parser_def = OutputParserDefinition { - ui_tree_source_step_id: None, - run: None, - javascript_code: Some( - r#" - const results = []; - - function findElementsRecursively(element) { - if (element.attributes && element.attributes.role === 'CheckBox') { - const item = { - productName: element.attributes.name || '', - id: element.attributes.id || '', - is_toggled: element.attributes.is_toggled || false - }; - results.push(item); - } - - if (element.children) { - for (const child of element.children) { - findElementsRecursively(child); - } - } - } - - findElementsRecursively(tree); - return results; - "# - .to_string(), - ), - javascript_file_path: None, - }; - // Note: This test would require an async runtime to execute JavaScript - // For now, we'll just verify the parser definition structure is correct - let parser_def_json = serde_json::to_value(&parser_def).unwrap(); - assert!(parser_def_json.get("javascript_code").is_some()); - } - - #[test] - fn test_empty_results() { - let parser_def = OutputParserDefinition { - ui_tree_source_step_id: None, - run: None, - javascript_code: Some( - r#" - const results = []; - - function findElementsRecursively(element) { - if (element.children) { - for (const child of element.children) { - findElementsRecursively(child); - } - } - } - - findElementsRecursively(tree); - return results; - "# - .to_string(), - ), - javascript_file_path: None, - }; - - // Verify parser definition structure - let parser_def_json = serde_json::to_value(&parser_def).unwrap(); - assert!(parser_def_json.get("javascript_code").is_some()); - } - - #[test] - fn test_step_id_lookup() { - json!({ - "children": [ - { - "attributes": { - "role": "CheckBox", - "name": "Found Product", - "id": "789" - } - } - ] - }); - - let parser_def = OutputParserDefinition { - ui_tree_source_step_id: Some("test_step".to_string()), - run: None, - javascript_code: Some( - r#" - const results = []; - - function findElementsRecursively(element) { - if (element.attributes && element.attributes.role === 'CheckBox') { - const item = { - productName: element.attributes.name || '', - id: element.attributes.id || '' - }; - results.push(item); - } - - if (element.children) { - for (const child of element.children) { - findElementsRecursively(child); - } - } - } - - findElementsRecursively(tree); - return results; - "# - .to_string(), - ), - javascript_file_path: None, - }; - - // Verify parser definition structure - let parser_def_json = serde_json::to_value(&parser_def).unwrap(); - assert!(parser_def_json.get("javascript_code").is_some()); - assert_eq!( - parser_def.ui_tree_source_step_id, - Some("test_step".to_string()) - ); - } - - #[test] - fn test_attribute_value_filtering() { - let parser_def = OutputParserDefinition { - ui_tree_source_step_id: None, - run: None, - javascript_code: Some( - r#" - const results = []; - - function findElementsRecursively(element) { - if (element.attributes && - element.attributes.role === 'CheckBox' && - element.attributes.is_toggled === true) { - - const item = { - productName: element.attributes.name || '' - }; - results.push(item); - } - - if (element.children) { - for (const child of element.children) { - findElementsRecursively(child); - } - } - } - - findElementsRecursively(tree); - return results; - "# - .to_string(), - ), - javascript_file_path: None, - }; - - json!({ - "children": [ - { - "attributes": { - "role": "CheckBox", - "name": "Toggled Checkbox", - "is_toggled": true - } - }, - { - "attributes": { - "role": "CheckBox", - "name": "Untoggled Checkbox", - "is_toggled": false - } - }, - { - "attributes": { - "role": "CheckBox", - "name": "Missing Toggle Checkbox" - } - } - ] - }); - - // Verify parser definition structure - let parser_def_json = serde_json::to_value(&parser_def).unwrap(); - assert!(parser_def_json.get("javascript_code").is_some()); - } - - #[test] - fn test_parser_definition_serialization() { - // Test the new clean syntax for JavaScript-based parsing - let parser_def_json = json!({ - "ui_tree_source_step_id": "capture_tree", - "javascript_code": "return [];" - }); - - let parser_def: OutputParserDefinition = serde_json::from_value(parser_def_json).unwrap(); - assert_eq!( - parser_def.ui_tree_source_step_id, - Some("capture_tree".to_string()) - ); - assert_eq!(parser_def.javascript_code, Some("return [];".to_string())); - } -} diff --git a/crates/terminator-mcp-agent/src/server_sequence.rs b/crates/terminator-mcp-agent/src/server_sequence.rs index 4247d7ad6..703ce7e6b 100644 --- a/crates/terminator-mcp-agent/src/server_sequence.rs +++ b/crates/terminator-mcp-agent/src/server_sequence.rs @@ -1,11 +1,11 @@ -use crate::helpers::substitute_variables; -use crate::output_parser; -use crate::server::extract_content_json; -use crate::telemetry::{StepSpan, WorkflowSpan}; + + + + use crate::utils::{ - DesktopWrapper, ExecuteSequenceArgs, SequenceItem, ToolCall, ToolGroup, VariableDefinition, + DesktopWrapper, ExecuteSequenceArgs, }; -use crate::workflow_format::{detect_workflow_format, WorkflowFormat}; + use crate::workflow_typescript::{TypeScriptWorkflow, WorkflowEvent}; use rmcp::model::{ CallToolResult, Content, LoggingLevel, LoggingMessageNotificationParam, NumberOrString, @@ -16,9 +16,9 @@ use rmcp::ErrorData as McpError; use serde_json::{json, Value}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; -use std::time::Duration; + use tokio::sync::mpsc; -use tracing::{debug, info, info_span, warn, Instrument}; +use tracing::{debug, info, info_span, Instrument}; use uuid::Uuid; /// RAII guard to automatically reset the in_sequence flag when dropped @@ -45,115 +45,6 @@ impl SequenceGuard { } } -/// Helper function to recursively validate a value against a variable definition -fn validate_variable_value( - variable_name: &str, - value: &Value, - def: &VariableDefinition, -) -> Result<(), McpError> { - match def.r#type { - crate::utils::VariableType::String => { - if !value.is_string() { - return Err(McpError::invalid_params( - format!("Variable '{variable_name}' must be a string."), - Some(json!({"value": value})), - )); - } - } - crate::utils::VariableType::Number => { - if !value.is_number() { - return Err(McpError::invalid_params( - format!("Variable '{variable_name}' must be a number."), - Some(json!({"value": value})), - )); - } - } - crate::utils::VariableType::Boolean => { - if !value.is_boolean() { - return Err(McpError::invalid_params( - format!("Variable '{variable_name}' must be a boolean."), - Some(json!({"value": value})), - )); - } - } - crate::utils::VariableType::Enum => { - let val_str = value.as_str().ok_or_else(|| { - McpError::invalid_params( - format!("Enum variable '{variable_name}' must be a string."), - Some(json!({"value": value})), - ) - })?; - if let Some(options) = &def.options { - if !options.contains(&val_str.to_string()) { - return Err(McpError::invalid_params( - format!("Variable '{variable_name}' has an invalid value."), - Some(json!({ - "value": val_str, - "allowed_options": options - })), - )); - } - } - } - crate::utils::VariableType::Array => { - if !value.is_array() { - return Err(McpError::invalid_params( - format!("Variable '{variable_name}' must be an array."), - Some(json!({"value": value})), - )); - } - // Validate each array item against item_schema if provided - if let Some(item_schema) = &def.item_schema { - if let Some(array) = value.as_array() { - for (index, item) in array.iter().enumerate() { - validate_variable_value( - &format!("{variable_name}[{index}]"), - item, - item_schema, - )?; - } - } - } - } - crate::utils::VariableType::Object => { - if !value.is_object() { - return Err(McpError::invalid_params( - format!("Variable '{variable_name}' must be an object."), - Some(json!({"value": value})), - )); - } - - let obj = value.as_object().unwrap(); - - // Validate against properties if defined (for objects with known structure) - if let Some(properties) = &def.properties { - for (prop_key, prop_def) in properties { - if let Some(prop_value) = obj.get(prop_key) { - validate_variable_value( - &format!("{variable_name}.{prop_key}"), - prop_value, - prop_def, - )?; - } else if prop_def.required.unwrap_or(true) { - return Err(McpError::invalid_params( - format!("Required property '{variable_name}.{prop_key}' is missing."), - None, - )); - } - } - } - - // Validate against value_schema if defined (for flat key-value objects) - if let Some(value_schema) = &def.value_schema { - for (key, val) in obj { - validate_variable_value(&format!("{variable_name}.{key}"), val, value_schema)?; - } - } - } - } - - Ok(()) -} impl DesktopWrapper { // Get the state file path for a workflow @@ -379,2254 +270,28 @@ impl DesktopWrapper { } } + async fn execute_sequence_inner( &self, peer: Peer, - request_context: RequestContext, - mut args: ExecuteSequenceArgs, + _request_context: RequestContext, + args: ExecuteSequenceArgs, execution_id: String, ) -> Result { // Set the in_sequence flag for the duration of this function - // This flag will be automatically reset to false when this guard is dropped let _sequence_guard = SequenceGuard::new(self.in_sequence.clone()); - // Validate that either URL or steps are provided - if args.url.is_none() && args.steps.as_ref().map(|s| s.is_empty()).unwrap_or(true) { - return Err(McpError::invalid_params( - "Either 'url' or 'steps' must be provided".to_string(), + // TypeScript workflows require a URL + let url = args.url.clone().ok_or_else(|| { + McpError::invalid_params( + "TypeScript workflows require a 'url' parameter pointing to a workflow directory or file".to_string(), None, - )); - } - - // Detect workflow format if URL is provided - if let Some(url) = &args.url { - let format = detect_workflow_format(url); - - match format { - WorkflowFormat::TypeScript => { - // Execute TypeScript workflow with MCP notification streaming - let url_clone = url.clone(); - return self - .execute_typescript_workflow(&url_clone, args, execution_id, peer) - .await; - } - WorkflowFormat::Yaml => { - // Continue with existing YAML workflow logic - info!("Detected YAML workflow format"); - } - } - } - - // Handle URL fetching if provided (YAML workflow path) - if let Some(url) = &args.url { - info!("Fetching workflow from URL: {}", url); - - let workflow_content = if url.starts_with("file://") { - // Handle local file URLs - let file_path = url.strip_prefix("file://").unwrap_or(url); - // Handle Windows file:/// URLs (strip leading / before drive letter like /C:) - let file_path = if file_path.starts_with('/') - && file_path.len() > 2 - && file_path.chars().nth(2) == Some(':') - { - &file_path[1..] - } else { - file_path - }; - info!("Reading file from path: {}", file_path); - - // Store the workflow directory for relative path resolution - let workflow_path = Path::new(file_path); - if let Some(parent_dir) = workflow_path.parent() { - let mut workflow_dir_guard = self.current_workflow_dir.lock().await; - *workflow_dir_guard = Some(parent_dir.to_path_buf()); - info!("Stored workflow directory: {:?}", parent_dir); - } - - let content = std::fs::read_to_string(file_path).map_err(|e| { - McpError::invalid_params( - format!("Failed to read local workflow file: {e}"), - Some(json!({"url": url, "error": e.to_string()})), - ) - })?; - info!("File content length: {}", content.len()); - content - } else if url.starts_with("http://") || url.starts_with("https://") { - // Handle HTTP/HTTPS URLs - let response = reqwest::get(url).await.map_err(|e| { - McpError::invalid_params( - format!("Failed to fetch workflow from URL: {e}"), - Some(json!({"url": url, "error": e.to_string()})), - ) - })?; - - if !response.status().is_success() { - return Err(McpError::invalid_params( - format!("HTTP error fetching workflow: {}", response.status()), - Some(json!({"url": url, "status": response.status().as_u16()})), - )); - } - - response.text().await.map_err(|e| { - McpError::invalid_params( - format!("Failed to read response text: {e}"), - Some(json!({"url": url, "error": e.to_string()})), - ) - })? - } else { - return Err(McpError::invalid_params( - "URL must start with http://, https://, or file://".to_string(), - Some(json!({"url": url})), - )); - }; - - // Debug: Log the raw YAML content - debug!( - "Raw YAML content (first 500 chars): {}", - workflow_content.chars().take(500).collect::() - ); - - // Parse the fetched YAML workflow - // First check if it's wrapped in execute_sequence structure - let remote_workflow: ExecuteSequenceArgs = if workflow_content - .contains("tool_name: execute_sequence") - { - // This workflow is wrapped in execute_sequence structure - // Parse as a generic Value first to extract the arguments - match serde_yaml::from_str::(&workflow_content) { - Ok(yaml_value) => { - if yaml_value.get("tool_name").and_then(|v| v.as_str()) - == Some("execute_sequence") - { - // Extract the arguments field - if let Some(arguments) = yaml_value.get("arguments") { - match serde_json::from_value::( - arguments.clone(), - ) { - Ok(wf) => { - info!( - "Successfully parsed wrapped YAML. Steps count: {}", - wf.steps.as_ref().map(|s| s.len()).unwrap_or(0) - ); - wf - } - Err(e) => { - tracing::error!( - "Failed to parse arguments from wrapped YAML: {}", - e - ); - return Err(McpError::invalid_params( - format!("Failed to parse workflow arguments: {e}"), - Some(json!({"url": url, "error": e.to_string()})), - )); - } - } - } else { - return Err(McpError::invalid_params( - "Workflow has execute_sequence but no arguments field" - .to_string(), - Some(json!({"url": url})), - )); - } - } else { - // Try parsing as regular ExecuteSequenceArgs - match serde_json::from_value::(yaml_value) { - Ok(wf) => { - info!( - "Successfully parsed YAML. Steps count: {}", - wf.steps.as_ref().map(|s| s.len()).unwrap_or(0) - ); - wf - } - Err(e) => { - tracing::error!("Failed to parse YAML: {}", e); - return Err(McpError::invalid_params( - format!("Failed to parse workflow YAML: {e}"), - Some(json!({"url": url, "error": e.to_string()})), - )); - } - } - } - } - Err(e) => { - tracing::error!("Failed to parse YAML as Value: {}", e); - return Err(McpError::invalid_params( - format!("Failed to parse YAML: {e}"), - Some(json!({"url": url, "error": e.to_string()})), - )); - } - } - } else { - // Standard format without execute_sequence wrapper - match serde_yaml::from_str::(&workflow_content) { - Ok(wf) => { - info!( - "Successfully parsed YAML. Steps count: {}", - wf.steps.as_ref().map(|s| s.len()).unwrap_or(0) - ); - wf - } - Err(e) => { - tracing::error!("Failed to parse YAML: {}", e); - return Err(McpError::invalid_params( - format!("Failed to parse remote workflow YAML: {e}"), - Some( - json!({"url": url, "error": e.to_string(), "content_preview": workflow_content.chars().take(200).collect::()}), - ), - )); - } - } - }; - - // Debug: Log what we got from the remote workflow - info!( - "Remote workflow parsed - steps present: {}, steps count: {}", - remote_workflow.steps.is_some(), - remote_workflow.steps.as_ref().map(|s| s.len()).unwrap_or(0) - ); - - // Merge remote workflow with local overrides - // Only use remote steps if local steps are empty or None - if args.steps.as_ref().map(|s| s.is_empty()).unwrap_or(true) { - args.steps = remote_workflow.steps; - } - // Also merge troubleshooting steps if not provided locally - if args - .troubleshooting - .as_ref() - .map(|t| t.is_empty()) - .unwrap_or(true) - { - args.troubleshooting = remote_workflow.troubleshooting; - } - if args.variables.is_none() { - args.variables = remote_workflow.variables; - } - if args.selectors.is_none() { - args.selectors = remote_workflow.selectors; - } - // Merge inputs: local inputs (from CLI) override remote inputs (from workflow file) - if args.inputs.is_none() && remote_workflow.inputs.is_some() { - args.inputs = remote_workflow.inputs; - } else if args.inputs.is_some() && remote_workflow.inputs.is_some() { - // If both exist, merge them with local taking precedence - if let (Some(local_inputs), Some(remote_inputs)) = - (&args.inputs, &remote_workflow.inputs) - { - if let (Some(local_obj), Some(remote_obj)) = - (local_inputs.as_object(), remote_inputs.as_object()) - { - let mut merged = remote_obj.clone(); - merged.extend(local_obj.clone()); - args.inputs = Some(serde_json::Value::Object(merged)); - } - } - } - - info!( - "After merge - args.steps present: {}, count: {}, inputs present: {}", - args.steps.is_some(), - args.steps.as_ref().map(|s| s.len()).unwrap_or(0), - args.inputs.is_some() - ); - - info!( - "Successfully loaded workflow from URL with {} steps", - args.steps.as_ref().map(|s| s.len()).unwrap_or(0) - ); - - // Also merge scripts_base_path if not provided locally - if args.scripts_base_path.is_none() { - args.scripts_base_path = remote_workflow.scripts_base_path; - } - // Also merge output_parser and output if not provided locally - if args.output_parser.is_none() { - args.output_parser = remote_workflow.output_parser; - } - if args.output.is_none() { - args.output = remote_workflow.output; - } - } - - // Set the scripts_base_path for file resolution in run_command and execute_browser_script - if let Some(scripts_base_path) = &args.scripts_base_path { - let mut scripts_base_path_guard = self.current_scripts_base_path.lock().await; - *scripts_base_path_guard = Some(scripts_base_path.clone()); - info!( - "[SCRIPTS_BASE_PATH] Setting scripts_base_path for workflow: {}", - scripts_base_path - ); - info!( - "[SCRIPTS_BASE_PATH] Script files will be searched first in: {}", - scripts_base_path - ); - info!("[SCRIPTS_BASE_PATH] Fallback search will use workflow directory or current directory"); - } else { - info!( - "[SCRIPTS_BASE_PATH] No scripts_base_path specified, using default file resolution" - ); - } - - // Handle backward compatibility: 'continue' is opposite of 'stop_on_error' - let stop_on_error = if let Some(continue_exec) = args.r#continue { - !continue_exec // continue=true means stop_on_error=false - } else { - args.stop_on_error.unwrap_or(true) - }; - - // Handle verbosity levels - // quiet: minimal output (just success/failure) - // normal: moderate output (includes tool results/logs but may omit some metadata) - // verbose: full output (includes all details and metadata) - let include_detailed = match args.verbosity.as_deref() { - Some("quiet") => false, - Some("verbose") => true, - Some("normal") | None => args.include_detailed_results.unwrap_or(false), // Changed default to false - _ => args.include_detailed_results.unwrap_or(false), // Changed default to false - }; - - // Re-enabling validation logic - if let Some(variable_schema) = &args.variables { - let inputs_map = args - .inputs - .as_ref() - .and_then(|v| v.as_object()) - .cloned() - .unwrap_or_default(); - - for (key, def) in variable_schema { - let value = inputs_map.get(key).or(def.default.as_ref()); - - match value { - Some(val) => { - // Use the recursive validation helper function - validate_variable_value(key, val, def)?; - } - None => { - if def.required.unwrap_or(true) { - return Err(McpError::invalid_params( - format!("Required variable '{key}' is missing."), - None, - )); - } - } - } - } - } - - // Build the execution context. It's a combination of the 'inputs' and 'selectors'. - // The context is a simple, flat map of variables that will be used for substitution in tool arguments. - let mut execution_context_map = serde_json::Map::new(); - - // First, populate with default values from variables schema - if let Some(variable_schema) = &args.variables { - for (key, def) in variable_schema { - if let Some(default_value) = &def.default { - execution_context_map.insert(key.clone(), default_value.clone()); - } - } - } - - // Then override with user-provided inputs (inputs take precedence over defaults) - if let Some(inputs) = &args.inputs { - // Validate inputs is an object - if let Err(err) = crate::utils::validate_inputs(inputs) { - return Err(McpError::invalid_params( - format!( - "Invalid inputs: {} expected {}, got {}", - err.field, err.expected, err.actual - ), - None, - )); - } - if let Some(inputs_map) = inputs.as_object() { - for (key, value) in inputs_map { - execution_context_map.insert(key.clone(), value.clone()); - } - } - } - - if let Some(selectors) = args.selectors.clone() { - // Validate selectors - if let Err(err) = crate::utils::validate_selectors(&selectors) { - return Err(McpError::invalid_params( - format!( - "Invalid selectors: {} expected {}, got {}", - err.field, err.expected, err.actual - ), - None, - )); - } - // If selectors is a string, parse it as JSON first - let selectors_value = if let serde_json::Value::String(s) = &selectors { - match serde_json::from_str::(s) { - Ok(parsed) => parsed, - Err(_) => selectors, // If parsing fails, treat it as a raw string - } - } else { - selectors - }; - execution_context_map.insert("selectors".to_string(), selectors_value); - } - - // Initialize an internal env bag with the inputs and other values - let mut env_map = serde_json::Map::new(); - - // Add all inputs to the env so they're accessible in JavaScript - if let Some(inputs) = &args.inputs { - if let Some(inputs_obj) = inputs.as_object() { - for (key, value) in inputs_obj { - env_map.insert(key.clone(), value.clone()); - } - // Also store the entire inputs object - env_map.insert("inputs".to_string(), inputs.clone()); - } - } - - execution_context_map.insert("env".to_string(), serde_json::Value::Object(env_map)); - - // Build a map from step ID to its index for quick lookup (includes both main and troubleshooting steps) - use std::collections::HashMap; - let mut id_to_index: HashMap = HashMap::new(); - - // Map main workflow steps - if let Some(steps) = &args.steps { - for (idx, step) in steps.iter().enumerate() { - if let Some(id) = &step.id { - if id_to_index.insert(id.clone(), idx).is_some() { - warn!( - "Duplicate step id '{}' found; later occurrence overrides earlier.", - id - ); - } - } - } - } - - // Track the boundary between main steps and troubleshooting steps - let main_steps_len = args.steps.as_ref().map(|s| s.len()).unwrap_or(0); - - // Map troubleshooting steps (they come after main steps in the sequence) - if let Some(troubleshooting) = &args.troubleshooting { - for (idx, step) in troubleshooting.iter().enumerate() { - if let Some(id) = &step.id { - let global_idx = main_steps_len + idx; - if id_to_index.insert(id.clone(), global_idx).is_some() { - warn!( - "Duplicate step id '{}' found in troubleshooting; later occurrence overrides earlier.", - id - ); - } - } - } - } - - // NEW: Check if we should start from a specific step (now searches both main and troubleshooting) - let start_from_index = if let Some(start_step) = &args.start_from_step { - // Find the step index by ID using the complete map - id_to_index.get(start_step).copied().ok_or_else(|| { - McpError::invalid_params( - format!("start_from_step '{start_step}' not found in workflow or troubleshooting steps"), - Some(json!({ - "requested_step": start_step, - "available_steps": id_to_index.keys().cloned().collect::>() - })), - ) - })? - } else { - 0 - }; - - // NEW: Check if we should end at a specific step (now searches both main and troubleshooting) - let end_at_index = if let Some(end_step) = &args.end_at_step { - // Find the step index by ID (inclusive) using the complete map - id_to_index.get(end_step).copied().ok_or_else(|| { - McpError::invalid_params( - format!( - "end_at_step '{end_step}' not found in workflow or troubleshooting steps" - ), - Some(json!({ - "requested_step": end_step, - "available_steps": id_to_index.keys().cloned().collect::>() - })), - ) - })? - } else { - // No end_at_step specified, run to the end of MAIN steps only - // This preserves the default behavior of not entering troubleshooting during normal execution - main_steps_len.saturating_sub(1) - }; - - // NEW: Load saved state if starting from a specific step - if start_from_index > 0 { - if let Some(saved_env) = - Self::load_workflow_state(args.workflow_id.as_deref(), args.url.as_deref()).await? - { - execution_context_map.insert("env".to_string(), saved_env); - debug!( - "Loaded saved env state for resuming from step {}", - start_from_index - ); - } - } - - let execution_context = Self::create_flattened_execution_context(&execution_context_map); - debug!( - "Executing sequence with context: {}", - serde_json::to_string_pretty(&execution_context).unwrap_or_default() - ); - // Extract attributes early for logging - let log_source = "agent"; - let trace_id_val = args.trace_id.as_deref().unwrap_or(""); - let execution_id_val = args.execution_id.as_deref().unwrap_or(""); - - info!( - log_source = %log_source, - execution_id = %execution_id_val, - trace_id = %trace_id_val, - steps = args.steps.as_ref().map(|s| s.len()).unwrap_or(0), - stop_on_error = %stop_on_error, - include_detailed = %include_detailed, - "Starting execute_sequence [execution_id={}, trace_id={}]", execution_id_val, trace_id_val - ); - - // Start workflow telemetry span - let workflow_name = "execute_sequence"; - let mut workflow_span = WorkflowSpan::new(workflow_name); - - // Add execution metadata for filtering/grouping - workflow_span.set_attribute("workflow.execution_id", execution_id.clone()); - workflow_span.set_attribute("log_source", log_source.to_string()); - - // Add trace_id for distributed tracing if provided by executor - if let Some(trace_id) = &args.trace_id { - workflow_span.set_attribute("trace_id", trace_id.clone()); - } - - // Add execution_id for distributed tracing if provided by executor - if let Some(exec_id) = &args.execution_id { - workflow_span.set_attribute("execution_id", exec_id.clone()); - } - - workflow_span.set_attribute( - "workflow.total_steps", - args.steps - .as_ref() - .map(|s| s.len()) - .unwrap_or(0) - .to_string(), - ); - workflow_span.set_attribute("workflow.stop_on_error", stop_on_error.to_string()); - - // Add workflow source metadata - if let Some(url) = &args.url { - workflow_span.set_attribute("workflow.url", url.clone()); - // Detect and set workflow format - let format = detect_workflow_format(url); - workflow_span.set_attribute("workflow.format", format!("{format:?}").to_lowercase()); - } else { - workflow_span.set_attribute("workflow.format", "inline".to_string()); - } - - // Add trigger source (from MCP API) - workflow_span.set_attribute("workflow.trigger_source", "mcp_api".to_string()); - - // Add organization/user context from environment if available - if let Ok(org_id) = std::env::var("ORGANIZATION_ID") { - workflow_span.set_attribute("organization.id", org_id); - } - if let Ok(user_id) = std::env::var("USER_ID") { - workflow_span.set_attribute("user.id", user_id); - } - - // Add execution mode from environment - let execution_mode = - std::env::var("EXECUTION_MODE").unwrap_or_else(|_| "normal".to_string()); - workflow_span.set_attribute("workflow.execution_mode", execution_mode); - - // Convert flattened SequenceStep to internal SequenceItem representation - let mut sequence_items = Vec::new(); - let empty_steps = Vec::new(); - let steps = args.steps.as_ref().unwrap_or(&empty_steps); - for (step_idx, step) in steps.iter().enumerate() { - let item = if let Some(tool_name) = &step.tool_name { - // Parse delay from either delay_ms or human-readable delay field - let delay_ms = if let Some(delay_str) = &step.delay { - match crate::duration_parser::parse_duration(delay_str) { - Ok(ms) => Some(ms), - Err(e) => { - warn!("Failed to parse delay '{}': {}", delay_str, e); - step.delay_ms // Fall back to delay_ms - } - } - } else { - step.delay_ms - }; - - let tool_call = ToolCall { - tool_name: tool_name.clone(), - arguments: step.arguments.clone().unwrap_or(serde_json::json!({})), - continue_on_error: step.continue_on_error, - delay_ms, - id: step.id.clone(), - }; - SequenceItem::Tool { tool_call } - } else if let Some(group_name) = &step.group_name { - let tool_group = ToolGroup { - group_name: group_name.clone(), - steps: step - .steps - .clone() - .unwrap_or_default() - .into_iter() - .map(|s| ToolCall { - tool_name: s.tool_name, - arguments: s.arguments, - continue_on_error: s.continue_on_error, - delay_ms: s.delay_ms, - id: s.id, - }) - .collect(), - skippable: step.skippable, - }; - SequenceItem::Group { tool_group } - } else { - let is_in_range = step_idx >= start_from_index && step_idx <= end_at_index; - let range_info = if is_in_range { - "This step IS in your execution range." - } else { - "This step is OUTSIDE your execution range but still blocks execution." - }; - return Err(McpError::invalid_params( - format!( - "Step {} is invalid: missing tool_name or group_name. {}", - step_idx + 1, - range_info - ), - Some(json!({ - "error_type": "invalid_step", - "step_index": step_idx + 1, - "step_id": step.id, - "is_in_execution_range": is_in_range, - "execution_range": { - "start": start_from_index + 1, - "end": end_at_index + 1 - } - })), - )); - }; - sequence_items.push(item); - } - - // Add troubleshooting steps to the sequence (they won't execute unless jumped to via fallback_id) - if let Some(troubleshooting) = &args.troubleshooting { - info!( - "Adding {} troubleshooting steps to workflow (accessible only via fallback_id)", - troubleshooting.len() - ); - for (local_idx, step) in troubleshooting.iter().enumerate() { - let global_step_idx = main_steps_len + local_idx; - let item = if let Some(tool_name) = &step.tool_name { - // Parse delay from either delay_ms or human-readable delay field - let delay_ms = if let Some(delay_str) = &step.delay { - match crate::duration_parser::parse_duration(delay_str) { - Ok(ms) => Some(ms), - Err(e) => { - warn!("Failed to parse delay '{}': {}", delay_str, e); - step.delay_ms // Fall back to delay_ms - } - } - } else { - step.delay_ms - }; - - let tool_call = ToolCall { - tool_name: tool_name.clone(), - arguments: step.arguments.clone().unwrap_or(serde_json::json!({})), - continue_on_error: step.continue_on_error, - delay_ms, - id: step.id.clone(), - }; - SequenceItem::Tool { tool_call } - } else if let Some(group_name) = &step.group_name { - let tool_group = ToolGroup { - group_name: group_name.clone(), - steps: step - .steps - .clone() - .unwrap_or_default() - .into_iter() - .map(|s| ToolCall { - tool_name: s.tool_name, - arguments: s.arguments, - continue_on_error: s.continue_on_error, - delay_ms: s.delay_ms, - id: s.id, - }) - .collect(), - skippable: step.skippable, - }; - SequenceItem::Group { tool_group } - } else { - let is_in_range = - global_step_idx >= start_from_index && global_step_idx <= end_at_index; - let range_info = if is_in_range { - "This step IS in your execution range." - } else { - "This step is OUTSIDE your execution range but still blocks execution." - }; - return Err(McpError::invalid_params( - format!( - "Troubleshooting step {} (global index {}) is invalid: missing tool_name or group_name. {}", - local_idx + 1, global_step_idx + 1, range_info - ), - Some(json!({ - "error_type": "invalid_step", - "step_index": global_step_idx + 1, - "troubleshooting_index": local_idx + 1, - "step_id": step.id, - "is_in_execution_range": is_in_range, - "execution_range": { - "start": start_from_index + 1, - "end": end_at_index + 1 - } - })), - )); - }; - sequence_items.push(item); - } - } - - // --------------------------- - // PRE-FLIGHT CHECK: Chrome Extension Health - // --------------------------- - // Check if workflow contains any execute_browser_script steps - // If yes, verify Chrome extension is connected before starting execution - let has_browser_script_steps = steps.iter().any(|step| { - step.tool_name - .as_ref() - .map(|t| t == "execute_browser_script") - .unwrap_or(false) - }) || args - .troubleshooting - .as_ref() - .map(|t| { - t.iter().any(|step| { - step.tool_name - .as_ref() - .map(|t| t == "execute_browser_script") - .unwrap_or(false) - }) - }) - .unwrap_or(false); - - // Check if we should run the pre-flight check - // Skip if: 1) no browser script steps, OR 2) skip_preflight_check flag is set - let should_run_preflight = - has_browser_script_steps && !args.skip_preflight_check.unwrap_or(false); - - if has_browser_script_steps && args.skip_preflight_check.unwrap_or(false) { - info!("Skipping browser extension pre-flight check (skip_preflight_check=true)"); - } - - if should_run_preflight { - info!( - "Workflow contains execute_browser_script steps - checking Chrome extension health" - ); - - // Initialize the extension bridge (starts WebSocket server on port 17373) - let bridge = terminator::extension_bridge::ExtensionBridge::global().await; - - // Trigger browser activity to wake up the extension - // Navigate to a blank page in CHROME to trigger the extension's content script - info!("Triggering Chrome browser activity to wake up extension..."); - match terminator::Desktop::new_default() { - Ok(desktop) => { - match desktop.open_url("about:blank", Some(terminator::Browser::Chrome)) { - Ok(_chrome_window) => { - info!("Chrome navigation triggered successfully"); - // Give the extension a moment to detect the page load and connect - tokio::time::sleep(Duration::from_millis(300)).await; - } - Err(e) => { - warn!("Failed to navigate Chrome: {:?}", e); - } - } - } - Err(e) => { - warn!("Failed to create Desktop instance: {:?}", e); - } - } - - // Now test extension connection with a minimal eval (ping) - // This uses the same 10-second retry logic as execute_browser_script - // The ping executes in the about:blank tab we just opened - info!("Testing Chrome extension connection with ping script..."); - let ping_result = bridge - .eval_in_active_tab("true", Duration::from_secs(10)) - .await; - - let is_connected = ping_result.is_ok() && ping_result.as_ref().unwrap().is_some(); - - // Get updated health status after connection attempt - let bridge_health = - terminator::extension_bridge::ExtensionBridge::health_status().await; - let status = bridge_health - .get("status") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let clients = bridge_health - .get("clients") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - - if !is_connected { - warn!( - "Chrome extension not connected before workflow execution: status={}, clients={}", - status, - clients - ); - - // End workflow span - workflow_span.set_status(false, "Chrome extension not available"); - workflow_span.end(); - - return Err(McpError::invalid_params( - "Chrome extension bridge is not connected", - Some(json!({ - "error_type": "extension_unavailable", - "extension_status": status, - "extension_clients": clients, - "workflow_file": args.url.as_ref(), - "browser_script_steps_count": sequence_items.iter().filter(|item| { - matches!(item, SequenceItem::Tool { tool_call } if tool_call.tool_name == "execute_browser_script") - }).count(), - "troubleshooting": [ - "Verify Chrome extension is installed and enabled at chrome://extensions", - "Extension ID: Check terminator browser-extension folder", - "Ensure Chrome browser is running", - "Check if WebSocket port 17373 is accessible", - "Try restarting Chrome browser", - "Review extension bridge health: http://127.0.0.1:3000/health (HTTP transport)" - ], - "health_details": bridge_health - })), - )); - } - - info!( - "โœ… Chrome extension healthy: {} client(s) connected", - clients - ); - - // Close the about:blank tab now that we've confirmed the extension works - match terminator::Desktop::new_default() { - Ok(desktop) => { - match desktop.press_key("{Ctrl}w").await { - Ok(_) => { - info!("Closed about:blank tab with Ctrl+W"); - // Wait for tab close to complete before starting workflow - tokio::time::sleep(Duration::from_millis(300)).await; - } - Err(e) => warn!("Failed to close about:blank tab: {:?}", e), - } - } - Err(e) => warn!("Failed to create Desktop instance for tab cleanup: {:?}", e), - } - } - - // --------------------------- - // Fallback-enabled execution loop (while-based) - // --------------------------- - - let mut results = Vec::new(); - let mut sequence_had_errors = false; - let mut critical_error_occurred = false; - let mut used_fallback = false; // Track if any fallback was used - let mut actually_executed_count = 0usize; // Track only steps that actually executed (not skipped) - let mut cancelled_by_user = false; // Track if execution was cancelled by user - let start_time = chrono::Utc::now(); - - let mut current_index: usize = start_from_index; - let max_iterations = sequence_items.len() * 10; // Prevent infinite fallback loops - let mut iterations = 0usize; - - // Track whether we've jumped to troubleshooting - let mut jumped_to_troubleshooting = false; - - // Track last executed process for window management - let mut last_executed_process: Option = None; - - // Detect if we're starting directly in the troubleshooting section - if start_from_index >= main_steps_len { - jumped_to_troubleshooting = true; - info!( - "Starting execution directly in troubleshooting section at step index {} (troubleshooting step #{})", - start_from_index, - start_from_index - main_steps_len + 1 - ); - } - - // Get follow_fallback setting - // - Default to true for unbounded execution (no end_at_step) to allow troubleshooting fallbacks - // - Default to false for bounded execution (with end_at_step) to respect boundaries - let follow_fallback = args.follow_fallback.unwrap_or(args.end_at_step.is_none()); - if args.end_at_step.is_some() { - info!("follow_fallback={} for bounded execution", follow_fallback); - } else { - info!("follow_fallback={} for unbounded execution (defaulting to true for troubleshooting access)", follow_fallback); - } - - // Log if we're skipping steps - if start_from_index > 0 { - let step_type = if start_from_index >= main_steps_len { - "troubleshooting" - } else { - "main workflow" - }; - info!( - "Starting from {} step at index {}", - step_type, start_from_index - ); - } - - // Log if we're stopping at a specific step - if end_at_index < sequence_items.len() - 1 { - let step_type = if end_at_index >= main_steps_len { - "troubleshooting" - } else { - "main workflow" - }; - info!( - "Will stop after {} step at index {} (inclusive)", - step_type, end_at_index - ); - } - - // Capture initial window state before executing any steps - // This captures state before step 0 (which might open new windows) - // Check if window management is enabled (defaults to true for backward compatibility) - let window_mgmt_enabled = args.window_mgmt.enable_window_management.unwrap_or(true); - if window_mgmt_enabled { - if let Err(e) = self.window_manager.capture_initial_state().await { - tracing::warn!( - "Failed to capture initial window state before sequence: {}", - e - ); - } else { - tracing::info!("Captured initial window state before sequence execution"); - } - } else { - tracing::debug!("Window management disabled for sequence, skipping capture"); - } - - while current_index < sequence_items.len() - && (current_index <= end_at_index || (follow_fallback && jumped_to_troubleshooting)) - && iterations < max_iterations - { - iterations += 1; - - // Check if the request has been cancelled - if request_context.ct.is_cancelled() { - warn!("Request cancelled by user, stopping sequence execution"); - cancelled_by_user = true; - break; // Exit loop gracefully and return partial results - } - - // Get the original step from either main steps or troubleshooting steps - let original_step = if current_index < main_steps_len { - args.steps.as_ref().and_then(|s| s.get(current_index)) - } else { - args.troubleshooting - .as_ref() - .and_then(|t| t.get(current_index - main_steps_len)) - }; - - // Extract values from the step if it exists - let (if_expr, retries, fallback_id_opt) = if let Some(step) = original_step { - ( - step.r#if.clone(), - step.retries.unwrap_or(0), - step.fallback_id.clone(), - ) - } else { - (None, 0, None) - }; - - let is_always_step = if_expr.as_deref().is_some_and(|s| s.trim() == "always()"); - - // If a critical error occurred and this step is NOT an 'always' step, skip it. - if critical_error_occurred && !is_always_step { - results.push(json!({ - "index": current_index, - "status": "skipped", - "executed": false, - "reason": "Skipped due to a previous unrecoverable error in the sequence." - })); - current_index += 1; - continue; - } - - // 1. Evaluate condition, unless it's an 'always' step. - if let Some(cond_str) = &if_expr { - let execution_context = - Self::create_flattened_execution_context(&execution_context_map); - if !is_always_step - && !crate::expression_eval::evaluate(cond_str, &execution_context) - { - info!( - "Skipping step {} due to if expression not met: `{}`", - current_index, cond_str - ); - results.push(json!({ - "index": current_index, - "status": "skipped", - "executed": false, - "reason": format!("if_expr not met: {}", cond_str) - })); - current_index += 1; - continue; - } - } - - // Log step BEGIN only after skip checks - this ensures we only log steps that will actually execute - if let Some(step) = original_step { - if let Some(tool_name) = &step.tool_name { - info!( - "Step {} BEGIN tool='{}' id='{}' retries={} if_expr={:?} fallback_id={:?} jumps={}", - current_index, - tool_name, - step.id.as_deref().unwrap_or(""), - step.retries.unwrap_or(0), - step.r#if, - step.fallback_id, - step.jumps.as_ref().map(|j| j.len()).unwrap_or(0) - ); - } else if let Some(group_name) = &step.group_name { - info!( - "Step {} BEGIN group='{}' id='{}' steps={}", - current_index, - group_name, - step.id.as_deref().unwrap_or(""), - step.steps.as_ref().map(|v| v.len()).unwrap_or(0) - ); - } - } - - // 2. Execute with retries - let mut final_result = json!(null); - let mut step_error_occurred = false; - let total_steps = sequence_items.len(); - - for attempt in 0..=retries { - let item = &mut sequence_items[current_index]; - match item { - SequenceItem::Tool { tool_call } => { - // Special internal pseudo-tool to set env for subsequent steps - let tool_name_normalized = tool_call - .tool_name - .strip_prefix("mcp_terminator-mcp-agent_") - .unwrap_or(&tool_call.tool_name) - .to_string(); - - // Substitute variables in arguments before execution - let execution_context = - Self::create_flattened_execution_context(&execution_context_map); - let mut substituted_args = tool_call.arguments.clone(); - substitute_variables(&mut substituted_args, &execution_context); - - // Inject workflow variables and accumulated env for run_command and execute_browser_script - if matches!( - tool_call.tool_name.as_str(), - "run_command" | "execute_browser_script" - ) { - // Get env object or create empty one - let mut env_obj = substituted_args - .get("env") - .and_then(|v| v.as_object()) - .cloned() - .unwrap_or_else(serde_json::Map::new); - - // Always inject workflow variables (scripts depend on them) - // Extract default values from VariableDefinition objects for consistency - if let Some(workflow_vars) = &args.variables { - let mut resolved_vars = serde_json::Map::new(); - - // Step 1: Start with defaults from variable schema - for (key, def) in workflow_vars { - if let Some(default_value) = &def.default { - resolved_vars.insert(key.clone(), default_value.clone()); - } - } - - // Step 2: Deep merge runtime inputs (overrides defaults) - // This allows UI-provided parameters to override variable defaults - if let Some(inputs) = &args.inputs { - tracing::debug!( - "[workflow_variables] Before merge: {}", - serde_json::to_string(&resolved_vars).unwrap_or_default() - ); - Self::deep_merge_json(&mut resolved_vars, inputs); - tracing::debug!( - "[workflow_variables] After merge: {}", - serde_json::to_string(&resolved_vars).unwrap_or_default() - ); - } - - env_obj.insert( - "_workflow_variables".to_string(), - json!(resolved_vars), - ); - } - - // Always inject accumulated env so scripts can access previous step results - if let Some(accumulated_env) = execution_context.get("env") { - env_obj.insert( - "_accumulated_env".to_string(), - accumulated_env.clone(), - ); - } - - // Update the arguments - if let Some(args_obj) = substituted_args.as_object_mut() { - args_obj.insert("env".to_string(), json!(env_obj)); - } - } - - // Start step telemetry span - let step_id = original_step.and_then(|s| s.id.as_deref()); - let mut step_span = StepSpan::new(&tool_call.tool_name, step_id); - step_span.set_attribute("step.number", (current_index + 1).to_string()); - step_span.set_attribute("step.total", total_steps.to_string()); - if attempt > 0 { - step_span.set_attribute("step.retry_attempt", attempt.to_string()); - } - - // Add workflow execution_id to step for correlation - step_span.set_attribute("workflow.execution_id", execution_id.clone()); - - // Extract and add step-level metadata for filtering/grouping - // Extract current process from arguments - let current_process = substituted_args - .get("process") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - if let Some(ref proc) = current_process { - step_span.set_attribute("step.process", proc.clone()); - } - - // Extract selector if present (common in UI automation tools) - if let Some(selector) = substituted_args.get("selector") { - let selector_str = if let Some(s) = selector.as_str() { - s.to_string() - } else if let Some(obj) = selector.as_object() { - // Handle selector object with "selector" field - obj.get("selector") - .and_then(|v| v.as_str()) - .unwrap_or("complex_selector") - .to_string() - } else { - "complex_selector".to_string() - }; - step_span.set_attribute("step.selector", selector_str); - } - - // Extract window_selector if present - if let Some(window_selector) = substituted_args - .get("window_selector") - .and_then(|v| v.as_str()) - { - step_span - .set_attribute("step.window_selector", window_selector.to_string()); - } - - // Extract URL for browser navigation tools - if let Some(url) = substituted_args.get("url").and_then(|v| v.as_str()) { - step_span.set_attribute("step.url", url.to_string()); - } - - // Extract text for typing tools - if let Some(text) = substituted_args.get("text").and_then(|v| v.as_str()) { - // Only log first 50 chars to avoid PII/sensitive data - let text_preview = if text.len() > 50 { - format!("{}...", &text[..50]) - } else { - text.to_string() - }; - step_span.set_attribute("step.text_length", text.len().to_string()); - step_span.set_attribute("step.text_preview", text_preview); - } - - // Add event for step started - workflow_span.add_event( - "step.started", - vec![ - ("step.tool", tool_call.tool_name.clone()), - ("step.index", current_index.to_string()), - ], - ); - - // Create execution context for window management + logging - let step_id = original_step.and_then(|s| s.id.clone()); - let execution_context = Some( - crate::utils::ToolExecutionContext::sequence_step( - args.url.clone().unwrap_or_default(), - current_index + 1, // 1-based for user display - total_steps, - last_executed_process.clone(), - ) - .with_workflow_context(args.workflow_id.clone(), step_id.clone()), - ); - - let (result, error_occurred) = self - .execute_single_tool( - peer.clone(), - request_context.clone(), - &tool_call.tool_name, - &substituted_args, - tool_call.continue_on_error.unwrap_or(false), - current_index, - include_detailed, - step_id.as_deref(), - execution_context, - ) - .await; - - final_result = result.clone(); - - // Update last_executed_process for window management - if let Some(ref proc) = current_process { - last_executed_process = Some(proc.clone()); - } - - // NEW: Store tool result in env if step has an ID (for ALL tools, not just scripts) - if let Some(step_id) = original_step.and_then(|s| s.id.as_deref()) { - let result_key = format!("{step_id}_result"); - let status_key = format!("{step_id}_status"); - - // Extract the meaningful content from the result - let mut result_content = - if let Some(result_obj) = final_result.get("result") { - // For tools, extract the actual content - if let Some(content) = result_obj.get("content") { - content.clone() - } else { - result_obj.clone() - } - } else { - // Fallback to the entire result if no nested structure - final_result.clone() - }; - - // REMOVE server_logs before storing in env (they're debug data, not operational data) - if let Some(obj) = result_content.as_object_mut() { - if obj.contains_key("server_logs") { - let log_count = obj - .get("server_logs") - .and_then(|logs| logs.as_array()) - .map(|arr| arr.len()) - .unwrap_or(0); - obj.remove("server_logs"); - debug!( - "Removed {} server_logs from {}_result before storing in env", - log_count, step_id - ); - } - } - - // Store at root level for easier expression access - execution_context_map - .insert(result_key.clone(), result_content.clone()); - execution_context_map - .insert(status_key.clone(), final_result["status"].clone()); - - // Also store in env - if let Some(env_value) = execution_context_map.get_mut("env") { - if let Some(env_map) = env_value.as_object_mut() { - env_map.insert(result_key.clone(), result_content); - env_map - .insert(status_key.clone(), final_result["status"].clone()); - - info!( - "Stored tool result for step '{}' as '{}' at root and env levels", - step_id, result_key - ); - - // Save state after storing tool result - Self::save_workflow_state( - args.workflow_id.as_deref(), - args.url.as_deref(), - Some(step_id), - current_index, - env_value, - ) - .await - .ok(); // Don't fail the workflow if state save fails - } - } - } - - // Update step span status and end it - // Support both 'status' field and 'success' field - let success = result["status"] == "success" - || result["success"] == true - || (result["status"].is_null() && result["success"] != false); - step_span.set_status( - success, - if !success { - result["error"].as_str() - } else { - None - }, - ); - step_span.end(); - - // Add workflow event for step completion - workflow_span.add_event( - "step.completed", - vec![ - ("step.tool", tool_call.tool_name.clone()), - ("step.index", current_index.to_string()), - ( - "step.status", - result["status"].as_str().unwrap_or("unknown").to_string(), - ), - ], - ); - - // Define reserved keys that shouldn't auto-merge - const RESERVED_KEYS: &[&str] = - &["status", "error", "logs", "duration_ms", "set_env"]; - - // Merge env updates from engine/script-based steps into the internal context - if (tool_name_normalized == "execute_browser_script" - || tool_name_normalized == "run_command") - && final_result["status"] == "success" - { - // Helper to merge updates into the env context map - let mut merge_env_obj = |update_val: &serde_json::Value| { - if let Some(update_map) = update_val.as_object() { - if let Some(env_value) = execution_context_map.get_mut("env") { - if let Some(env_map) = env_value.as_object_mut() { - for (k, v) in update_map.iter() { - env_map.insert(k.clone(), v.clone()); - } - } - } - } - }; - - // Special handling for execute_browser_script - if tool_name_normalized == "execute_browser_script" { - // Browser scripts return their result as a plain string in final_result["result"]["content"][0]["result"] - if let Some(result_str) = final_result - .get("result") - .and_then(|r| r.get("content")) - .and_then(|c| c.as_array()) - .and_then(|arr| arr.first()) - .and_then(|item| item.get("result")) - .and_then(|r| r.as_str()) - { - info!( - "[execute_browser_script] Browser script returned: {}", - result_str - ); - // Try to parse the browser script result as JSON - match serde_json::from_str::(result_str) { - Ok(parsed_json) => { - info!("[execute_browser_script] Successfully parsed browser result as JSON"); - - // First handle explicit set_env for backward compatibility - if let Some(set_env) = parsed_json.get("set_env") { - info!("[execute_browser_script] Found set_env in browser script result, merging into context"); - merge_env_obj(set_env); - } - - // Then auto-merge non-reserved fields - if let Some(obj) = parsed_json.as_object() { - if let Some(env_value) = - execution_context_map.get_mut("env") - { - if let Some(env_map) = env_value.as_object_mut() - { - for (k, v) in obj { - if RESERVED_KEYS.contains(&k.as_str()) { - warn!( - "[execute_browser_script] Script returned reserved field '{}' which will be ignored. Reserved fields: {:?}", - k, RESERVED_KEYS - ); - } else { - env_map - .insert(k.clone(), v.clone()); - info!("[execute_browser_script] Auto-merged field '{}' to env", k); - } - } - } - } - } - } - Err(e) => { - info!("[execute_browser_script] Browser result is not JSON: {}", e); - } - } - } else { - info!("[execute_browser_script] Could not extract browser script result string from response structure"); - } - } else if tool_name_normalized == "run_command" { - // Original logic for run_command - if let Some(content_arr) = final_result - .get("result") - .and_then(|r| r.get("content")) - .and_then(|c| c.as_array()) - { - for item in content_arr { - // Typical engine payload is under item.result - if let Some(res) = item.get("result") { - // First handle explicit set_env/env for backward compatibility - if let Some(v) = - res.get("set_env").or_else(|| res.get("env")) - { - merge_env_obj(v); - } - } - // Also support top-level set_env/env directly on the item - if let Some(v) = - item.get("set_env").or_else(|| item.get("env")) - { - merge_env_obj(v); - } - } - - // Auto-merge non-reserved fields from run_command results - for item in content_arr { - if let Some(res) = item.get("result") { - if let Some(obj) = res.as_object() { - if let Some(env_value) = - execution_context_map.get_mut("env") - { - if let Some(env_map) = env_value.as_object_mut() - { - for (k, v) in obj { - if !RESERVED_KEYS.contains(&k.as_str()) - { - env_map - .insert(k.clone(), v.clone()); - info!("[run_command] Auto-merged field '{}' to env", k); - } - } - } - } - } - } - } - - // NEW: Auto-merge non-reserved fields from root level - // This enables scripts to return data directly without wrapping in 'result' - for item in content_arr { - // Try two approaches: - // 1. If the result is a JSON string, parse it and merge fields - // 2. If the item itself is an object with fields, merge those - - // Approach 1: Parse JSON string from result field - if let Some(result_str) = - item.get("result").and_then(|r| r.as_str()) - { - // Try to parse the result string as JSON - if let Ok(parsed_json) = - serde_json::from_str::( - result_str, - ) - { - if let Some(parsed_obj) = parsed_json.as_object() { - if let Some(env_value) = - execution_context_map.get_mut("env") - { - if let Some(env_map) = - env_value.as_object_mut() - { - // Define structural keys that should not be merged - const STRUCTURAL_KEYS: &[&str] = &[ - "result", "action", "mode", - "engine", "content", - ]; - - for (k, v) in parsed_obj { - // Check if it's a reserved key - if RESERVED_KEYS - .contains(&k.as_str()) - { - warn!( - "[run_command] Script returned reserved field '{}' at root level which will be ignored. Reserved fields: {:?}", - k, RESERVED_KEYS - ); - continue; - } - - // Skip structural keys silently - if STRUCTURAL_KEYS - .contains(&k.as_str()) - { - continue; - } - - // Merge the field (overwrite to ensure updates) - env_map - .insert(k.clone(), v.clone()); - info!("[run_command] Auto-merged root field '{}' from parsed JSON to env", k); - } - } - } - } - } - } - - // Approach 2: Direct object fields (for backward compatibility) - if let Some(obj) = item.as_object() { - if let Some(env_value) = - execution_context_map.get_mut("env") - { - if let Some(env_map) = env_value.as_object_mut() { - // Define structural keys that should not be merged - const STRUCTURAL_KEYS: &[&str] = &[ - "result", "action", "mode", "engine", - "content", - ]; - - for (k, v) in obj { - // Check if it's a reserved key - if RESERVED_KEYS.contains(&k.as_str()) { - warn!( - "[run_command] Script returned reserved field '{}' at root level which will be ignored. Reserved fields: {:?}", - k, RESERVED_KEYS - ); - continue; - } - - // Skip structural keys silently - if STRUCTURAL_KEYS.contains(&k.as_str()) { - continue; - } - - // Merge the field (overwrite to ensure updates) - env_map.insert(k.clone(), v.clone()); - debug!("[run_command] Auto-merged root field '{}' to env", k); - } - } - } - } - } - } - } - - // NEW: Save state after env update - if let Some(env_value) = execution_context_map.get("env") { - Self::save_workflow_state( - args.workflow_id.as_deref(), - args.url.as_deref(), - original_step.and_then(|s| s.id.as_deref()), - current_index, - env_value, - ) - .await - .ok(); // Don't fail the workflow if state save fails - } - } - // Check for success using both 'status' and 'success' fields - if result["status"] == "success" - || result["success"] == true - || (result["status"].is_null() && result["success"] != false) - { - // Apply delay after successful execution - if let Some(delay_ms) = tool_call.delay_ms { - if delay_ms > 0 { - tokio::time::sleep(Duration::from_millis(delay_ms)).await; - } - } - break; - } - - if error_occurred { - // Only mark as critical if there's no fallback to handle it - if fallback_id_opt.is_none() { - critical_error_occurred = true; - if let Some(id) = original_step.and_then(|s| s.id.as_deref()) { - tracing::warn!( - step_id = %id, - tool = %tool_call.tool_name, - attempt = attempt + 1, - skippable = %tool_call.continue_on_error.unwrap_or(false), - has_fallback = false, - "Tool failed with unrecoverable error (no fallback)" - ); - } else { - tracing::warn!( - tool = %tool_call.tool_name, - attempt = attempt + 1, - skippable = %tool_call.continue_on_error.unwrap_or(false), - has_fallback = false, - "Tool failed with unrecoverable error (no fallback)" - ); - } - } else { - // Has fallback, log but don't mark as critical - if let Some(id) = original_step.and_then(|s| s.id.as_deref()) { - tracing::info!( - step_id = %id, - tool = %tool_call.tool_name, - fallback_id = %fallback_id_opt.as_ref().unwrap(), - "Tool failed but has fallback configured" - ); - } else { - tracing::info!( - tool = %tool_call.tool_name, - fallback_id = %fallback_id_opt.as_ref().unwrap(), - "Tool failed but has fallback configured" - ); - } - } - } - step_error_occurred = true; - sequence_had_errors = true; - - if let Some(delay_ms) = tool_call.delay_ms { - if delay_ms > 0 { - tokio::time::sleep(Duration::from_millis(delay_ms)).await; - } - } - } - SequenceItem::Group { tool_group } => { - let mut group_had_errors = false; - let mut group_results = Vec::new(); - let is_skippable = tool_group.skippable.unwrap_or(false); - - for (step_index, step_tool_call) in tool_group.steps.iter_mut().enumerate() - { - // Substitute variables in arguments before execution - let execution_context = - Self::create_flattened_execution_context(&execution_context_map); - let mut substituted_args = step_tool_call.arguments.clone(); - substitute_variables(&mut substituted_args, &execution_context); - - // Extract current process from arguments - let current_process = substituted_args - .get("process") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - // Create execution context for window management + logging - let step_id_for_ctx = step_tool_call.id.clone(); - let tool_execution_context = Some( - crate::utils::ToolExecutionContext::sequence_step( - args.url.clone().unwrap_or_default(), - current_index + 1, // 1-based for user display - total_steps, - last_executed_process.clone(), - ) - .with_workflow_context( - args.workflow_id.clone(), - step_id_for_ctx.clone(), - ), - ); - - let (result, error_occurred) = self - .execute_single_tool( - peer.clone(), - request_context.clone(), - &step_tool_call.tool_name, - &substituted_args, - step_tool_call.continue_on_error.unwrap_or(false), - step_index, - include_detailed, - step_id_for_ctx.as_deref(), // Use step ID if available - tool_execution_context, - ) - .await; - - group_results.push(result.clone()); - - // Update last_executed_process for window management - if let Some(ref proc) = current_process { - last_executed_process = Some(proc.clone()); - } - - if let Some(delay_ms) = step_tool_call.delay_ms { - if delay_ms > 0 { - tokio::time::sleep(Duration::from_millis(delay_ms)).await; - } - } - - // Check for failure using both 'status' and 'success' fields - let tool_failed = !(result["status"] == "success" - || result["success"] == true - || (result["status"].is_null() && result["success"] != false)); - if tool_failed { - group_had_errors = true; - if error_occurred || is_skippable { - if error_occurred && !is_skippable { - // Only mark as critical if there's no fallback to handle it - if fallback_id_opt.is_none() { - critical_error_occurred = true; - } - } - tracing::warn!( - group = %tool_group.group_name, - tool = %step_tool_call.tool_name, - step_index = step_index, - step_id = %step_tool_call.id.clone().unwrap_or_default(), - skippable = %is_skippable, - has_fallback = fallback_id_opt.is_some(), - "Group step failed; breaking out of group" - ); - break; - } - } - } - - let group_status = if group_had_errors { - "partial_success" - } else { - "success" - }; - - if group_status != "success" { - sequence_had_errors = true; - step_error_occurred = true; - } - - if group_had_errors && !is_skippable && stop_on_error { - // Only mark as critical if there's no fallback to handle it - if fallback_id_opt.is_none() { - critical_error_occurred = true; - } - } - - final_result = json!({ - "group_name": &tool_group.group_name, - "status": group_status, - "results": group_results - }); - - if !group_had_errors { - break; // Group succeeded, break retry loop. - } - } - } - if attempt < retries { - warn!( - "Step {} failed on attempt {}/{}. Retrying...", - current_index, - attempt + 1, - retries - ); - tokio::time::sleep(Duration::from_millis(500)).await; // Wait before retry - } - } - - // Mark this step as executed (not skipped) and add to results - if let Some(obj) = final_result.as_object_mut() { - obj.insert("executed".to_string(), json!(true)); - } - results.push(final_result); - actually_executed_count += 1; - - // Decide next index based on success or fallback - let step_succeeded = !step_error_occurred; - let step_status_str = if step_succeeded { "success" } else { "failed" }; - if let Some(tool_name) = original_step.and_then(|s| s.tool_name.as_ref()) { - info!( - "Step {} END tool='{}' id='{}' status={}", - current_index, - tool_name, - original_step.and_then(|s| s.id.as_deref()).unwrap_or(""), - step_status_str - ); - } else if let Some(group_name) = original_step.and_then(|s| s.group_name.as_ref()) { - info!( - "Step {} END group='{}' id='{}' status={}", - current_index, - group_name, - original_step.and_then(|s| s.id.as_deref()).unwrap_or(""), - step_status_str - ); - } - - if step_succeeded { - // Check for conditional jumps on success - let mut performed_jump = false; - - // Check if we should skip jump evaluation at the end_at_step boundary - // When end_at_step is specified, jumps are skipped by default at the boundary - // to provide predictable execution bounds. Users can override this with - // --execute-jumps-at-end to allow jumps even at the boundary (e.g., for loops). - let execute_jumps_at_end = args.execute_jumps_at_end.unwrap_or(false); - let skip_jumps = current_index == end_at_index && !execute_jumps_at_end; - - if skip_jumps { - info!( - "Skipping jump evaluation at end_at_step boundary (step index {}). Use --execute-jumps-at-end to enable jumps at boundary.", - current_index - ); - } else if let Some(jumps) = original_step.and_then(|s| s.jumps.as_ref()) { - if !jumps.is_empty() { - info!( - "Evaluating {} jump condition(s) for step {}", - jumps.len(), - current_index - ); - - let execution_context = - Self::create_flattened_execution_context(&execution_context_map); - - for (idx, jump) in jumps.iter().enumerate() { - debug!( - "Evaluating jump condition {}/{}: {}", - idx + 1, - jumps.len(), - jump.condition - ); - - if crate::expression_eval::evaluate(&jump.condition, &execution_context) - { - // This condition matched - perform the jump - if let Some(&target_idx) = id_to_index.get(&jump.to_id) { - let reason = jump - .reason - .as_ref() - .map(|r| format!(": \"{r}\"")) - .unwrap_or_default(); - - info!( - "Step {} succeeded. Jump condition {}/{} matched{}. Jumping to '{}' (index {})", - current_index, idx + 1, jumps.len(), reason, jump.to_id, target_idx - ); - - // Check if jumping into troubleshooting section - if target_idx >= main_steps_len && !jumped_to_troubleshooting { - jumped_to_troubleshooting = true; - info!( - "Entered troubleshooting section via conditional jump" - ); - } - - current_index = target_idx; - performed_jump = true; - break; // Stop evaluating remaining conditions - } else { - warn!( - "Jump target '{}' not found for step {} condition {}. Continuing to next condition.", - jump.to_id, current_index, idx + 1 - ); - } - } else { - debug!("Jump condition {}/{} did not match", idx + 1, jumps.len()); - } - } - - if !performed_jump { - debug!("No jump conditions matched for step {}", current_index); - } - } - } - - // Only increment if we didn't jump - if !performed_jump { - // For successful steps, check if we're about to enter troubleshooting section - if !jumped_to_troubleshooting && current_index >= main_steps_len - 1 { - // We're at or past the last main step and haven't jumped to troubleshooting - // Exit the loop to prevent entering troubleshooting during normal flow - info!("Completed all main workflow steps successfully"); - break; - } - current_index += 1; - } - } else if let Some(fb_id) = fallback_id_opt { - if let Some(&fb_idx) = id_to_index.get(&fb_id) { - // Check if we should follow this fallback based on end_at_step and follow_fallback setting - let should_follow_fallback = if args.end_at_step.is_some() - && current_index >= end_at_index - { - // We're at or past end_at_step boundary - if follow_fallback { - info!( - "Step {} failed at end_at_step boundary. Following fallback to '{}' (follow_fallback=true).", - current_index, fb_id - ); - true - } else { - info!( - "Step {} failed at end_at_step boundary. NOT following fallback '{}' (follow_fallback=false).", - current_index, fb_id - ); - false - } - } else { - // Normal execution, always follow fallback - true - }; - - if should_follow_fallback { - info!( - "Step {} failed. Jumping to fallback step with id '{}' (index {}).", - current_index, fb_id, fb_idx - ); - - // Mark that we used a fallback - used_fallback = true; - - // Check if we're jumping into the troubleshooting section - if fb_idx >= main_steps_len { - jumped_to_troubleshooting = true; - info!("Entered troubleshooting section via fallback"); - } - - current_index = fb_idx; - } else { - // Don't follow fallback, treat as normal failure - // Break the loop since we're at end_at_step and not following fallback - info!( - "Stopping execution at end_at_step boundary without following fallback" - ); - break; - } - } else { - warn!( - "fallback_id '{}' for step {} not found. Continuing to next step.", - fb_id, current_index - ); - current_index += 1; - } - } else { - // Step failed with no fallback - current_index += 1; - } - } - - if iterations >= max_iterations { - warn!("Maximum iteration count reached. Possible infinite fallback loop detected."); - } - - let total_duration = (chrono::Utc::now() - start_time).num_milliseconds(); - - // Determine final status - simple success or failure, or cancelled - let final_status = if cancelled_by_user { - "cancelled" - } else if !sequence_had_errors { - "success" - } else { - "failed" - }; - info!( - log_source = %log_source, - execution_id = %execution_id_val, - trace_id = %trace_id_val, - status = %final_status, - executed_tools = %actually_executed_count, - total_results = %results.len(), - total_duration_ms = %total_duration, - cancelled = %cancelled_by_user, - "execute_sequence completed" - ); - - let mut summary = json!({ - "action": "execute_ts_workflow", - "status": final_status, - "total_tools": sequence_items.len(), - "executed_tools": actually_executed_count, - "total_results": results.len(), - "total_duration_ms": total_duration, - "timestamp": chrono::Utc::now().to_rfc3339(), - "used_fallback": used_fallback, - "results": results, - "env": execution_context_map.get("env").cloned().unwrap_or_else(|| json!({})), - }); - - // Support both 'output_parser' (legacy) and 'output' (simplified) - let parser_def = args.output_parser.as_ref().or(args.output.as_ref()); - - // Skip output parser when end_at_step is specified (partial execution) - if let Some(parser_def) = parser_def { - if args.end_at_step.is_some() { - warn!( - "Skipping output parser for partial workflow execution (end_at_step specified)" - ); - if let Some(obj) = summary.as_object_mut() { - obj.insert( - "parser_skipped".to_string(), - json!("Partial execution with end_at_step"), - ); - } - } else { - // Apply variable substitution to the output_parser field - let mut parser_json = parser_def.clone(); - let execution_context = - Self::create_flattened_execution_context(&execution_context_map); - substitute_variables(&mut parser_json, &execution_context); - - match output_parser::run_output_parser(&parser_json, &summary).await { - Ok(Some(parsed_data)) => { - // Check if the parsed data is wrapped in a 'result' field and unwrap it - // This handles the case where JavaScript execution via scripting_engine returns - // {result: , logs: [...]} wrapper structure. - // We need to extract the actual parser output from the wrapper to ensure - // the CLI and downstream consumers receive the parser's intended structure. - let final_data = if let Some(result) = parsed_data.get("result") { - // Log that we're unwrapping for debugging visibility - info!( - "[output_parser] Unwrapping parser result from JavaScript execution wrapper" - ); - // Unwrap the result field to get the actual parser output - result.clone() - } else { - // Use as-is if not wrapped (backward compatibility with direct returns) - parsed_data - }; - - if let Some(obj) = summary.as_object_mut() { - obj.insert("parsed_output".to_string(), final_data); - } - } - Ok(None) => { - if let Some(obj) = summary.as_object_mut() { - obj.insert("parsed_output".to_string(), json!({})); - } - } - Err(e) => { - if let Some(obj) = summary.as_object_mut() { - obj.insert("parser_error".to_string(), json!(e.to_string())); - } - } - } - } - } - if final_status != "success" { - // Capture minimal structured debug info so failures are not opaque - let debug_info = json!({ - "final_status": final_status, - "had_critical_error": critical_error_occurred, - "had_errors": sequence_had_errors, - "used_fallback": used_fallback, - "executed_count": actually_executed_count, - "total_results": results.len(), - }); - - if let Some(obj) = summary.as_object_mut() { - obj.insert("debug_info_on_failure".to_string(), debug_info); - } - } - - let contents = vec![Content::json(summary.clone())?]; - - // End workflow span with appropriate status - let span_success = matches!(final_status, "success"); - let span_message = if span_success { - "Workflow completed successfully" - } else { - "Workflow failed" - }; - - workflow_span.set_status(span_success, span_message); - workflow_span.add_event( - "workflow.completed", - vec![ - ("workflow.total_steps", results.len().to_string()), - ("workflow.final_status", final_status.to_string()), - ("workflow.used_fallback", used_fallback.to_string()), - ], - ); - workflow_span.end(); - - // Restore windows after sequence completion (success or failure) - // This ensures windows are restored even if sequence fails mid-execution - if window_mgmt_enabled { - if let Err(e) = self.window_manager.restore_all_windows().await { - tracing::warn!("Failed to restore windows after sequence: {}", e); - } else { - tracing::info!("Restored all windows to original state after sequence"); - } - self.window_manager.clear_captured_state().await; - } else { - tracing::debug!("Window management disabled for sequence, skipping restore"); - } - - Ok(CallToolResult::success(contents)) - } - - #[allow(clippy::too_many_arguments)] - pub async fn execute_single_tool( - &self, - peer: Peer, - request_context: RequestContext, - tool_name: &str, - arguments: &Value, - is_skippable: bool, - index: usize, - include_detailed: bool, - step_id: Option<&str>, - execution_context: Option, - ) -> (serde_json::Value, bool) { - let tool_start_time = chrono::Utc::now(); - let tool_name_short = tool_name - .strip_prefix("mcp_terminator-mcp-agent_") - .unwrap_or(tool_name); - - // Start log capture if in verbose mode - if include_detailed { - if let Some(ref log_capture) = self.log_capture { - log_capture.start_capture(); - } - } - - // The substitution is handled in `execute_sequence_impl`. - let tool_result = self - .dispatch_tool( - peer, - request_context, - tool_name_short, - arguments, - execution_context, ) - .await; - - let (processed_result, error_occurred) = match tool_result { - Ok(result) => { - let mut extracted_content = Vec::new(); - - if !result.content.is_empty() { - for content in &result.content { - match extract_content_json(content) { - Ok(json_content) => extracted_content.push(json_content), - Err(_) => extracted_content.push( - json!({ "type": "unknown", "data": "Content extraction failed" }), - ), - } - } - } - - let content_count = result.content.len(); - let content_summary = if include_detailed { - // Verbose mode: include full content/step definitions - json!({ "type": "tool_result", "content_count": content_count, "content": extracted_content }) - } else { - // Normal/quiet mode: include extracted content (logs/output) but not step definitions - // The extracted_content already contains just the results, not the tool arguments/definitions - json!({ - "type": "tool_result", - "status": "success", - "content_count": content_count, - "content": extracted_content - }) - }; - let duration_ms = (chrono::Utc::now() - tool_start_time).num_milliseconds(); - let mut result_json = json!({ - "tool_name": tool_name, - "index": index, - "status": "success", - "duration_ms": duration_ms, - "result": content_summary, - }); - - // Add step_id if provided - if let Some(id) = step_id { - if let Some(obj) = result_json.as_object_mut() { - obj.insert("step_id".to_string(), json!(id)); - } - } + })?; - // Capture server logs if in verbose mode - if include_detailed { - if let Some(ref log_capture) = self.log_capture { - let captured_logs = log_capture.stop_capture(); - if !captured_logs.is_empty() { - if let Some(obj) = result_json.as_object_mut() { - obj.insert("server_logs".to_string(), json!(captured_logs)); - } - } - } - } - - // Extract and add logs if present (for run_command) - if tool_name_short == "run_command" { - // Debug: log what's in extracted content - for (i, content) in extracted_content.iter().enumerate() { - if let Some(logs) = content.get("logs") { - info!( - "[execute_single_tool] Found logs in content[{}]: {} entries", - i, - logs.as_array().map(|a| a.len()).unwrap_or(0) - ); - } - } - - // Look for logs in the extracted content - if let Some(logs) = extracted_content - .iter() - .find_map(|c| c.get("logs").cloned()) - { - info!("[execute_single_tool] Adding logs to result_json"); - if let Some(obj) = result_json.as_object_mut() { - obj.insert("logs".to_string(), logs); - } - } else { - info!("[execute_single_tool] No logs found in extracted content"); - } - } - - let result_json = - serde_json::Value::Object(result_json.as_object().unwrap().clone()); - (result_json, false) - } - Err(e) => { - // Stop log capture on error and collect logs - let captured_logs = if include_detailed { - self.log_capture - .as_ref() - .map(|log_capture| log_capture.stop_capture()) - } else { - None - }; - - let duration_ms = (chrono::Utc::now() - tool_start_time).num_milliseconds(); - let mut error_result = json!({ - "tool_name": tool_name, - "index": index, - "status": if is_skippable { "skipped" } else { "error" }, - "duration_ms": duration_ms, - "error": format!("{}", e), - }); - - // Include server logs in error result if captured - if let Some(logs) = captured_logs { - if !logs.is_empty() { - if let Some(obj) = error_result.as_object_mut() { - obj.insert("server_logs".to_string(), json!(logs)); - } - } - } - - // Add step_id if provided - if let Some(id) = step_id { - if let Some(obj) = error_result.as_object_mut() { - obj.insert("step_id".to_string(), json!(id)); - } - } - - let error_result = - serde_json::Value::Object(error_result.as_object().unwrap().clone()); - - if !is_skippable { - warn!( - "Tool '{}' at index {} failed. Reason: {}", - tool_name, index, e - ); - } - (error_result, !is_skippable) - } - }; - - (processed_result, error_occurred) + // Execute TypeScript workflow with MCP notification streaming + self.execute_typescript_workflow(&url, args, execution_id, peer).await } - - /// Execute TypeScript workflow with MCP notification streaming async fn execute_typescript_workflow( &self, url: &str, diff --git a/crates/terminator-mcp-agent/src/utils.rs b/crates/terminator-mcp-agent/src/utils.rs index 28d21b2ad..6b1055bd9 100644 --- a/crates/terminator-mcp-agent/src/utils.rs +++ b/crates/terminator-mcp-agent/src/utils.rs @@ -1460,16 +1460,6 @@ pub struct ExecuteSequenceArgs { description = "Whether to include detailed results from each tool execution (default: true)" )] pub include_detailed_results: Option, - #[schemars( - description = "An optional, structured parser to process the final tool output and extract structured data." - )] - pub output_parser: Option, - - // Simplified aliases for common parameters (keeping originals for backward compatibility) - #[schemars( - description = "Simplified alias for 'output_parser'. Processes the final tool output and extracts structured data. Supports JavaScript code or file path." - )] - pub output: Option, #[schemars( description = "Continue execution on errors. Opposite of stop_on_error. When true, workflow continues even if steps fail (default: false)." @@ -1780,39 +1770,6 @@ pub fn validate_selectors(selectors: &serde_json::Value) -> Result<(), Validatio } } -pub fn validate_output_parser(parser: &serde_json::Value) -> Result<(), ValidationError> { - let obj = parser - .as_object() - .ok_or_else(|| ValidationError::new("output_parser", "object", &format!("{parser:?}")))?; - - // Check required fields - if !obj.contains_key("uiTreeJsonPath") { - return Err(ValidationError::new( - "output_parser.uiTreeJsonPath", - "string", - "missing", - )); - } - - if !obj.contains_key("itemContainerDefinition") { - return Err(ValidationError::new( - "output_parser.itemContainerDefinition", - "object", - "missing", - )); - } - - if !obj.contains_key("fieldsToExtract") { - return Err(ValidationError::new( - "output_parser.fieldsToExtract", - "object", - "missing", - )); - } - - Ok(()) -} - // Removed: RunJavascriptArgs (merged into RunCommandArgs via engine + script) pub fn init_logging() -> Result> { diff --git a/crates/terminator-mcp-agent/src/workflow_format.rs b/crates/terminator-mcp-agent/src/workflow_format.rs deleted file mode 100644 index be0d9e73b..000000000 --- a/crates/terminator-mcp-agent/src/workflow_format.rs +++ /dev/null @@ -1,210 +0,0 @@ -// Workflow format detection - YAML vs TypeScript - -use std::path::Path; - -#[derive(Debug, Clone, PartialEq)] -pub enum WorkflowFormat { - Yaml, - TypeScript, -} - -/// Detect workflow format from URL -pub fn detect_workflow_format(url: &str) -> WorkflowFormat { - // Handle file:// URLs - if url.starts_with("file://") { - let path_str = url.strip_prefix("file://").unwrap_or(url); - // Handle Windows file:/// URLs (strip leading / before drive letter like /C:) - let path_str = if path_str.starts_with('/') - && path_str.len() > 2 - && path_str.chars().nth(2) == Some(':') - { - &path_str[1..] - } else { - path_str - }; - let path = Path::new(path_str); - - // Check if it's a directory - if path.is_dir() { - // Look for package.json AND terminator.ts/workflow.ts/index.ts - let package_json = path.join("package.json"); - let terminator_ts = path.join("terminator.ts"); - let workflow_ts = path.join("workflow.ts"); - let index_ts = path.join("index.ts"); - - // Also check src/ subfolder for TypeScript files - let src_terminator_ts = path.join("src").join("terminator.ts"); - let src_workflow_ts = path.join("src").join("workflow.ts"); - let src_index_ts = path.join("src").join("index.ts"); - - if package_json.exists() - && (terminator_ts.exists() - || workflow_ts.exists() - || index_ts.exists() - || src_terminator_ts.exists() - || src_workflow_ts.exists() - || src_index_ts.exists()) - { - return WorkflowFormat::TypeScript; - } - } else if path.is_file() { - // Check file extension - if let Some(ext) = path.extension() { - match ext.to_str() { - Some("ts") | Some("js") => return WorkflowFormat::TypeScript, - Some("yml") | Some("yaml") => return WorkflowFormat::Yaml, - _ => {} - } - } - } - } - - // Default to YAML for backward compatibility (includes http/https URLs) - WorkflowFormat::Yaml -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use tempfile::TempDir; - - #[test] - fn test_detect_yaml_file() { - let temp_dir = TempDir::new().unwrap(); - let yaml_file = temp_dir.path().join("workflow.yml"); - fs::write(&yaml_file, "steps: []").unwrap(); - - let url = format!("file://{}", yaml_file.display()); - assert_eq!(detect_workflow_format(&url), WorkflowFormat::Yaml); - } - - #[test] - fn test_detect_yaml_file_yaml_extension() { - let temp_dir = TempDir::new().unwrap(); - let yaml_file = temp_dir.path().join("workflow.yaml"); - fs::write(&yaml_file, "steps: []").unwrap(); - - let url = format!("file://{}", yaml_file.display()); - assert_eq!(detect_workflow_format(&url), WorkflowFormat::Yaml); - } - - #[test] - fn test_detect_ts_file() { - let temp_dir = TempDir::new().unwrap(); - let ts_file = temp_dir.path().join("workflow.ts"); - fs::write(&ts_file, "export default {};").unwrap(); - - let url = format!("file://{}", ts_file.display()); - assert_eq!(detect_workflow_format(&url), WorkflowFormat::TypeScript); - } - - #[test] - fn test_detect_js_file() { - let temp_dir = TempDir::new().unwrap(); - let js_file = temp_dir.path().join("workflow.js"); - fs::write(&js_file, "export default {};").unwrap(); - - let url = format!("file://{}", js_file.display()); - assert_eq!(detect_workflow_format(&url), WorkflowFormat::TypeScript); - } - - #[test] - fn test_detect_ts_project() { - let temp_dir = TempDir::new().unwrap(); - let project_dir = temp_dir.path().join("project"); - fs::create_dir(&project_dir).unwrap(); - fs::write(project_dir.join("package.json"), "{}").unwrap(); - fs::write(project_dir.join("workflow.ts"), "export default {};").unwrap(); - - let url = format!("file://{}", project_dir.display()); - assert_eq!(detect_workflow_format(&url), WorkflowFormat::TypeScript); - } - - #[test] - fn test_detect_ts_project_with_index() { - let temp_dir = TempDir::new().unwrap(); - let project_dir = temp_dir.path().join("project"); - fs::create_dir(&project_dir).unwrap(); - fs::write(project_dir.join("package.json"), "{}").unwrap(); - fs::write(project_dir.join("index.ts"), "export default {};").unwrap(); - - let url = format!("file://{}", project_dir.display()); - assert_eq!(detect_workflow_format(&url), WorkflowFormat::TypeScript); - } - - #[test] - fn test_detect_ts_project_with_src_folder() { - let temp_dir = TempDir::new().unwrap(); - let project_dir = temp_dir.path().join("project"); - fs::create_dir(&project_dir).unwrap(); - fs::create_dir(project_dir.join("src")).unwrap(); - fs::write(project_dir.join("package.json"), "{}").unwrap(); - fs::write( - project_dir.join("src").join("terminator.ts"), - "export default {};", - ) - .unwrap(); - - let url = format!("file://{}", project_dir.display()); - assert_eq!(detect_workflow_format(&url), WorkflowFormat::TypeScript); - } - - #[test] - fn test_detect_ts_project_with_src_workflow() { - let temp_dir = TempDir::new().unwrap(); - let project_dir = temp_dir.path().join("project"); - fs::create_dir(&project_dir).unwrap(); - fs::create_dir(project_dir.join("src")).unwrap(); - fs::write(project_dir.join("package.json"), "{}").unwrap(); - fs::write( - project_dir.join("src").join("workflow.ts"), - "export default {};", - ) - .unwrap(); - - let url = format!("file://{}", project_dir.display()); - assert_eq!(detect_workflow_format(&url), WorkflowFormat::TypeScript); - } - - #[test] - fn test_detect_ts_project_with_src_index() { - let temp_dir = TempDir::new().unwrap(); - let project_dir = temp_dir.path().join("project"); - fs::create_dir(&project_dir).unwrap(); - fs::create_dir(project_dir.join("src")).unwrap(); - fs::write(project_dir.join("package.json"), "{}").unwrap(); - fs::write( - project_dir.join("src").join("index.ts"), - "export default {};", - ) - .unwrap(); - - let url = format!("file://{}", project_dir.display()); - assert_eq!(detect_workflow_format(&url), WorkflowFormat::TypeScript); - } - - #[test] - fn test_detect_directory_without_package_json() { - let temp_dir = TempDir::new().unwrap(); - let project_dir = temp_dir.path().join("project"); - fs::create_dir(&project_dir).unwrap(); - fs::write(project_dir.join("workflow.ts"), "export default {};").unwrap(); - - let url = format!("file://{}", project_dir.display()); - // Should default to YAML if no package.json - assert_eq!(detect_workflow_format(&url), WorkflowFormat::Yaml); - } - - #[test] - fn test_http_url_defaults_to_yaml() { - assert_eq!( - detect_workflow_format("https://example.com/workflow.yml"), - WorkflowFormat::Yaml - ); - assert_eq!( - detect_workflow_format("http://example.com/workflow.yaml"), - WorkflowFormat::Yaml - ); - } -} diff --git a/crates/terminator-mcp-agent/tests/integration/test_workflow_compatibility.rs b/crates/terminator-mcp-agent/tests/integration/test_workflow_compatibility.rs deleted file mode 100644 index 80a270306..000000000 --- a/crates/terminator-mcp-agent/tests/integration/test_workflow_compatibility.rs +++ /dev/null @@ -1,743 +0,0 @@ -// Integration tests for workflow backward and forward compatibility -// Tests YAML workflows, TypeScript workflows, state caching, start/stop functionality - -use serde_json::json; -use std::fs; -use std::path::PathBuf; -use tempfile::TempDir; - -#[cfg(test)] -mod workflow_compatibility_tests { - use super::*; - - // ======================================================================== - // Test Fixtures - // ======================================================================== - - /// Create a temporary YAML workflow file - fn create_yaml_workflow(temp_dir: &TempDir, name: &str, content: &str) -> PathBuf { - let workflow_path = temp_dir.path().join(format!("{}.yml", name)); - fs::write(&workflow_path, content).expect("Failed to write YAML workflow"); - workflow_path - } - - /// Create a temporary TypeScript workflow project - fn create_ts_workflow(temp_dir: &TempDir, name: &str) -> PathBuf { - let project_dir = temp_dir.path().join(name); - fs::create_dir(&project_dir).expect("Failed to create project dir"); - - // package.json - let package_json = json!({ - "name": name, - "type": "module", - "dependencies": { - "terminator.js": "^0.19.0", - "zod": "^3.22.4" - } - }); - fs::write( - project_dir.join("package.json"), - serde_json::to_string_pretty(&package_json).unwrap(), - ) - .expect("Failed to write package.json"); - - // workflow.ts - let workflow_ts = r#" -import { createStep, createWorkflow } from '@mediar-ai/workflow'; -import { z } from 'zod'; - -const step1 = createStep({ - id: 'step1', - name: 'Step 1', - execute: async ({ logger, context }) => { - logger.info('Executing step 1'); - context.data.step1 = { executed: true }; - return { result: 'step1 complete' }; - }, -}); - -const step2 = createStep({ - id: 'step2', - name: 'Step 2', - execute: async ({ logger, context }) => { - logger.info('Executing step 2'); - context.data.step2 = { executed: true, fromStep1: context.data.step1 }; - return { result: 'step2 complete' }; - }, -}); - -const step3 = createStep({ - id: 'step3', - name: 'Step 3', - execute: async ({ logger, context }) => { - logger.info('Executing step 3'); - context.data.step3 = { executed: true }; - return { result: 'step3 complete' }; - }, -}); - -export default createWorkflow({ - name: 'Test Workflow', - description: 'Test workflow for compatibility testing', - version: '1.0.0', - input: z.object({ - testInput: z.string().default('test'), - }), -}) - .step(step1) - .step(step2) - .step(step3) - .build(); -"#; - fs::write(project_dir.join("workflow.ts"), workflow_ts) - .expect("Failed to write workflow.ts"); - - project_dir - } - - // ======================================================================== - // YAML Backward Compatibility Tests - // ======================================================================== - - #[tokio::test] - async fn test_yaml_basic_execution() { - let temp_dir = TempDir::new().unwrap(); - let workflow = create_yaml_workflow( - &temp_dir, - "basic", - r#" -steps: - - id: step1 - name: Echo Hello - tool_name: run_command - arguments: - run: echo "Hello from YAML" -"#, - ); - - let result = execute_sequence(json!({ - "url": format!("file://{}", workflow.display()), - })) - .await; - - assert!(result.is_ok(), "YAML workflow execution failed"); - let output = result.unwrap(); - assert_eq!(output["status"], "success"); - } - - #[tokio::test] - async fn test_yaml_multiple_steps() { - let temp_dir = TempDir::new().unwrap(); - let workflow = create_yaml_workflow( - &temp_dir, - "multiple", - r#" -steps: - - id: step1 - name: Step 1 - tool_name: run_command - arguments: - run: echo "Step 1" - - - id: step2 - name: Step 2 - tool_name: run_command - arguments: - run: echo "Step 2" - - - id: step3 - name: Step 3 - tool_name: run_command - arguments: - run: echo "Step 3" -"#, - ); - - let result = execute_sequence(json!({ - "url": format!("file://{}", workflow.display()), - })) - .await; - - assert!(result.is_ok()); - let output = result.unwrap(); - assert_eq!(output["status"], "success"); - } - - #[tokio::test] - async fn test_yaml_with_variables() { - let temp_dir = TempDir::new().unwrap(); - let workflow = create_yaml_workflow( - &temp_dir, - "variables", - r#" -variables: - userName: - type: string - label: User Name - default: World - -steps: - - id: greet - name: Greet User - tool_name: run_command - arguments: - run: echo "Hello {{userName}}" -"#, - ); - - let result = execute_sequence(json!({ - "url": format!("file://{}", workflow.display()), - "inputs": { - "userName": "TestUser" - } - })) - .await; - - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_yaml_start_from_step() { - let temp_dir = TempDir::new().unwrap(); - let workflow = create_yaml_workflow( - &temp_dir, - "start_from", - r#" -steps: - - id: step1 - name: Step 1 - tool_name: run_command - arguments: - run: echo "Step 1" - - - id: step2 - name: Step 2 - tool_name: run_command - arguments: - run: echo "Step 2" - - - id: step3 - name: Step 3 - tool_name: run_command - arguments: - run: echo "Step 3" -"#, - ); - - // Create fake state file - let state_dir = temp_dir.path().join(".mediar").join("workflows").join("start_from"); - fs::create_dir_all(&state_dir).unwrap(); - let state_file = state_dir.join("state.json"); - fs::write( - &state_file, - json!({ - "last_step_id": "step1", - "last_step_index": 0, - "env": { - "step1_result": { "output": "Step 1" }, - "step1_status": "success" - } - }) - .to_string(), - ) - .unwrap(); - - let result = execute_sequence(json!({ - "url": format!("file://{}", workflow.display()), - "start_from_step": "step2" - })) - .await; - - assert!(result.is_ok()); - // Should skip step1, execute step2 and step3 - } - - #[tokio::test] - async fn test_yaml_end_at_step() { - let temp_dir = TempDir::new().unwrap(); - let workflow = create_yaml_workflow( - &temp_dir, - "end_at", - r#" -steps: - - id: step1 - name: Step 1 - tool_name: run_command - arguments: - run: echo "Step 1" - - - id: step2 - name: Step 2 - tool_name: run_command - arguments: - run: echo "Step 2" - - - id: step3 - name: Step 3 - tool_name: run_command - arguments: - run: echo "Step 3" -"#, - ); - - let result = execute_sequence(json!({ - "url": format!("file://{}", workflow.display()), - "end_at_step": "step2" - })) - .await; - - assert!(result.is_ok()); - // Should execute step1 and step2, skip step3 - } - - #[tokio::test] - async fn test_yaml_state_persistence() { - let temp_dir = TempDir::new().unwrap(); - let workflow = create_yaml_workflow( - &temp_dir, - "persistence", - r#" -steps: - - id: step1 - name: Step 1 - tool_name: run_command - arguments: - run: echo "Step 1" -"#, - ); - - let result = execute_sequence(json!({ - "url": format!("file://{}", workflow.display()), - })) - .await; - - assert!(result.is_ok()); - - // Check state file was created - let state_file = temp_dir.path().join(".mediar/workflows/persistence/state.json"); - assert!(state_file.exists(), "State file should exist"); - - let state_content = fs::read_to_string(&state_file).unwrap(); - let state: serde_json::Value = serde_json::from_str(&state_content).unwrap(); - - assert_eq!(state["last_step_id"], "step1"); - assert!(state["env"].is_object()); - } - - // ======================================================================== - // TypeScript Workflow Tests - // ======================================================================== - - #[tokio::test] - async fn test_ts_basic_execution() { - let temp_dir = TempDir::new().unwrap(); - let project_dir = create_ts_workflow(&temp_dir, "ts-basic"); - - // Install dependencies - install_dependencies(&project_dir).await; - - let result = execute_sequence(json!({ - "url": format!("file://{}", project_dir.display()), - "inputs": { - "testInput": "test" - } - })) - .await; - - assert!(result.is_ok(), "TS workflow execution failed"); - let output = result.unwrap(); - assert_eq!(output["status"], "success"); - } - - #[tokio::test] - async fn test_ts_start_from_step() { - let temp_dir = TempDir::new().unwrap(); - let project_dir = create_ts_workflow(&temp_dir, "ts-start-from"); - - install_dependencies(&project_dir).await; - - // First run: execute step1 - let result1 = execute_sequence(json!({ - "url": format!("file://{}", project_dir.display()), - "end_at_step": "step1", - "inputs": { "testInput": "test" } - })) - .await; - - assert!(result1.is_ok()); - - // Second run: resume from step2 - let result2 = execute_sequence(json!({ - "url": format!("file://{}", project_dir.display()), - "start_from_step": "step2", - "inputs": { "testInput": "test" } - })) - .await; - - assert!(result2.is_ok()); - let output = result2.unwrap(); - - // Verify state was restored - assert!(output["state"]["context"]["data"]["step1"].is_object()); - } - - #[tokio::test] - async fn test_ts_end_at_step() { - let temp_dir = TempDir::new().unwrap(); - let project_dir = create_ts_workflow(&temp_dir, "ts-end-at"); - - install_dependencies(&project_dir).await; - - let result = execute_sequence(json!({ - "url": format!("file://{}", project_dir.display()), - "end_at_step": "step2", - "inputs": { "testInput": "test" } - })) - .await; - - assert!(result.is_ok()); - let output = result.unwrap(); - - // Should have executed step1 and step2, but not step3 - assert!(output["state"]["stepResults"]["step1"].is_object()); - assert!(output["state"]["stepResults"]["step2"].is_object()); - assert!(output["state"]["stepResults"]["step3"].is_null()); - } - - #[tokio::test] - async fn test_ts_state_persistence() { - let temp_dir = TempDir::new().unwrap(); - let project_dir = create_ts_workflow(&temp_dir, "ts-persistence"); - - install_dependencies(&project_dir).await; - - let result = execute_sequence(json!({ - "url": format!("file://{}", project_dir.display()), - "end_at_step": "step2", - "inputs": { "testInput": "test" } - })) - .await; - - assert!(result.is_ok()); - - // Check state file was created - let state_file = project_dir.join(".mediar/workflows/workflow/state.json"); - assert!(state_file.exists(), "TS state file should exist"); - - let state_content = fs::read_to_string(&state_file).unwrap(); - let state: serde_json::Value = serde_json::from_str(&state_content).unwrap(); - - assert_eq!(state["last_step_id"], "step2"); - assert_eq!(state["last_step_index"], 1); - assert!(state["env"]["context"].is_object()); - } - - #[tokio::test] - async fn test_ts_context_sharing() { - let temp_dir = TempDir::new().unwrap(); - let project_dir = create_ts_workflow(&temp_dir, "ts-context"); - - install_dependencies(&project_dir).await; - - let result = execute_sequence(json!({ - "url": format!("file://{}", project_dir.display()), - "inputs": { "testInput": "test" } - })) - .await; - - assert!(result.is_ok()); - let output = result.unwrap(); - - // Verify step2 received context from step1 - let step2_result = &output["state"]["stepResults"]["step2"]["result"]; - assert!(step2_result["fromStep1"]["executed"].as_bool().unwrap()); - } - - #[tokio::test] - async fn test_ts_metadata_extraction() { - let temp_dir = TempDir::new().unwrap(); - let project_dir = create_ts_workflow(&temp_dir, "ts-metadata"); - - install_dependencies(&project_dir).await; - - let result = execute_sequence(json!({ - "url": format!("file://{}", project_dir.display()), - "inputs": { "testInput": "test" } - })) - .await; - - assert!(result.is_ok()); - let output = result.unwrap(); - - // Verify metadata was extracted - assert_eq!(output["metadata"]["name"], "Test Workflow"); - assert_eq!(output["metadata"]["version"], "1.0.0"); - assert_eq!(output["metadata"]["steps"].as_array().unwrap().len(), 3); - assert_eq!(output["metadata"]["steps"][0]["id"], "step1"); - assert_eq!(output["metadata"]["steps"][0]["name"], "Step 1"); - } - - // ======================================================================== - // Cross-Format Compatibility Tests - // ======================================================================== - - #[tokio::test] - async fn test_yaml_then_ts_workflow() { - let temp_dir = TempDir::new().unwrap(); - - // Execute YAML workflow - let yaml_workflow = create_yaml_workflow( - &temp_dir, - "yaml_first", - r#" -steps: - - id: yaml_step - name: YAML Step - tool_name: run_command - arguments: - run: echo "YAML" -"#, - ); - - let result1 = execute_sequence(json!({ - "url": format!("file://{}", yaml_workflow.display()), - })) - .await; - - assert!(result1.is_ok()); - - // Execute TS workflow - let ts_project = create_ts_workflow(&temp_dir, "ts_second"); - install_dependencies(&ts_project).await; - - let result2 = execute_sequence(json!({ - "url": format!("file://{}", ts_project.display()), - "inputs": { "testInput": "test" } - })) - .await; - - assert!(result2.is_ok()); - // Both should work independently - } - - #[tokio::test] - async fn test_format_detection_yaml_file() { - let temp_dir = TempDir::new().unwrap(); - let workflow = create_yaml_workflow(&temp_dir, "detect_yaml", "steps: []"); - - let format = detect_workflow_format(&format!("file://{}", workflow.display())) - .await - .unwrap(); - - assert!(matches!(format, WorkflowFormat::Yaml)); - } - - #[tokio::test] - async fn test_format_detection_ts_file() { - let temp_dir = TempDir::new().unwrap(); - let ts_file = temp_dir.path().join("workflow.ts"); - fs::write(&ts_file, "export default {};").unwrap(); - - let format = detect_workflow_format(&format!("file://{}", ts_file.display())) - .await - .unwrap(); - - assert!(matches!(format, WorkflowFormat::TypeScript)); - } - - #[tokio::test] - async fn test_format_detection_ts_project() { - let temp_dir = TempDir::new().unwrap(); - let project_dir = create_ts_workflow(&temp_dir, "detect_ts_project"); - - let format = detect_workflow_format(&format!("file://{}", project_dir.display())) - .await - .unwrap(); - - assert!(matches!(format, WorkflowFormat::TypeScript)); - } - - // ======================================================================== - // Runtime Detection Tests - // ======================================================================== - - #[tokio::test] - async fn test_bun_runtime_detection() { - let runtime = detect_js_runtime(); - // Should prefer bun if available, otherwise node - assert!(matches!(runtime, JsRuntime::Bun) || matches!(runtime, JsRuntime::Node)); - } - - #[tokio::test] - async fn test_ts_execution_with_bun() { - let temp_dir = TempDir::new().unwrap(); - let project_dir = create_ts_workflow(&temp_dir, "ts-bun"); - - // Install with bun if available - if matches!(detect_js_runtime(), JsRuntime::Bun) { - install_dependencies_with_bun(&project_dir).await; - - let result = execute_sequence(json!({ - "url": format!("file://{}", project_dir.display()), - "inputs": { "testInput": "test" } - })) - .await; - - assert!(result.is_ok(), "Bun execution should work"); - } - } - - #[tokio::test] - async fn test_ts_execution_with_node() { - let temp_dir = TempDir::new().unwrap(); - let project_dir = create_ts_workflow(&temp_dir, "ts-node"); - - install_dependencies_with_node(&project_dir).await; - - let result = execute_sequence(json!({ - "url": format!("file://{}", project_dir.display()), - "inputs": { "testInput": "test" } - })) - .await; - - assert!(result.is_ok(), "Node execution should work"); - } - - // ======================================================================== - // Error Handling Tests - // ======================================================================== - - #[tokio::test] - async fn test_yaml_invalid_step() { - let temp_dir = TempDir::new().unwrap(); - let workflow = create_yaml_workflow( - &temp_dir, - "invalid", - r#" -steps: - - id: bad_step - tool_name: nonexistent_tool - arguments: {} -"#, - ); - - let result = execute_sequence(json!({ - "url": format!("file://{}", workflow.display()), - })) - .await; - - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_ts_workflow_error_handling() { - let temp_dir = TempDir::new().unwrap(); - let project_dir = temp_dir.path().join("ts-error"); - fs::create_dir(&project_dir).unwrap(); - - // Create workflow that throws error - let workflow_ts = r#" -import { createStep, createWorkflow } from '@mediar-ai/workflow'; -import { z } from 'zod'; - -const errorStep = createStep({ - id: 'error_step', - name: 'Error Step', - execute: async () => { - throw new Error('Intentional error'); - }, -}); - -export default createWorkflow({ - name: 'Error Test', - input: z.object({}), -}) - .step(errorStep) - .build(); -"#; - fs::write(project_dir.join("workflow.ts"), workflow_ts).unwrap(); - create_package_json(&project_dir); - - install_dependencies(&project_dir).await; - - let result = execute_sequence(json!({ - "url": format!("file://{}", project_dir.display()), - })) - .await; - - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_missing_start_step() { - let temp_dir = TempDir::new().unwrap(); - let workflow = create_yaml_workflow( - &temp_dir, - "missing_start", - r#" -steps: - - id: step1 - tool_name: run_command - arguments: - run: echo "Step 1" -"#, - ); - - let result = execute_sequence(json!({ - "url": format!("file://{}", workflow.display()), - "start_from_step": "nonexistent_step" - })) - .await; - - assert!(result.is_err()); - } - - // ======================================================================== - // Helper Functions - // ======================================================================== - - async fn execute_sequence(args: serde_json::Value) -> Result { - // Mock implementation - in real tests, call actual execute_sequence_impl - // This would be replaced with actual server call - todo!("Implement actual execute_sequence call") - } - - async fn install_dependencies(project_dir: &PathBuf) { - // Install with bun if available, otherwise npm - match detect_js_runtime() { - JsRuntime::Bun => install_dependencies_with_bun(project_dir).await, - JsRuntime::Node => install_dependencies_with_node(project_dir).await, - } - } - - async fn install_dependencies_with_bun(project_dir: &PathBuf) { - std::process::Command::new("bun") - .arg("install") - .current_dir(project_dir) - .output() - .expect("Failed to install dependencies with bun"); - } - - async fn install_dependencies_with_node(project_dir: &PathBuf) { - std::process::Command::new("npm") - .arg("install") - .current_dir(project_dir) - .output() - .expect("Failed to install dependencies with npm"); - } - - fn create_package_json(project_dir: &PathBuf) { - let package_json = json!({ - "type": "module", - "dependencies": { - "terminator.js": "^0.19.0", - "zod": "^3.22.4" - } - }); - fs::write( - project_dir.join("package.json"), - serde_json::to_string_pretty(&package_json).unwrap(), - ) - .unwrap(); - } -} diff --git a/crates/terminator-mcp-agent/tests/test-workflow.yml b/crates/terminator-mcp-agent/tests/test-workflow.yml deleted file mode 100644 index a39d8fee4..000000000 --- a/crates/terminator-mcp-agent/tests/test-workflow.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Test Workflow for OpenTelemetry -description: Simple workflow to test telemetry tracing - -steps: - - tool: screenshot - id: capture_screen - description: Take a screenshot - - - tool: wait - delay: 500ms - id: wait_half_second - description: Wait for 500ms - - - tool: screenshot - id: capture_screen_after - description: Take another screenshot \ No newline at end of file diff --git a/crates/terminator-mcp-agent/tests/test_js_engine.rs b/crates/terminator-mcp-agent/tests/test_js_engine.rs index e1597b9ac..7c7a1f3ef 100644 --- a/crates/terminator-mcp-agent/tests/test_js_engine.rs +++ b/crates/terminator-mcp-agent/tests/test_js_engine.rs @@ -8,7 +8,7 @@ async fn test_javascript_engine_basic() { // Test basic JavaScript execution with the new 'run' parameter let script = "return {success: true, value: 42};".to_string(); - let result = scripting_engine::execute_javascript_with_nodejs(script, None, None) + let result = scripting_engine::execute_javascript_with_nodejs(script, None, None, None, None, None) .await .expect("JavaScript execution should succeed"); @@ -29,7 +29,7 @@ async fn test_javascript_engine_with_async() { "# .to_string(); - let result = scripting_engine::execute_javascript_with_nodejs(script, None, None) + let result = scripting_engine::execute_javascript_with_nodejs(script, None, None, None, None, None) .await .expect("Async JavaScript execution should succeed"); @@ -53,7 +53,7 @@ async fn test_javascript_engine_with_desktop_api() { "# .to_string(); - let result = scripting_engine::execute_javascript_with_nodejs(script, None, None) + let result = scripting_engine::execute_javascript_with_nodejs(script, None, None, None, None, None) .await .expect("Desktop API check should succeed"); diff --git a/crates/terminator-mcp-agent/tests/test_output_parser_compat.rs b/crates/terminator-mcp-agent/tests/test_output_parser_compat.rs deleted file mode 100644 index 551981dd7..000000000 --- a/crates/terminator-mcp-agent/tests/test_output_parser_compat.rs +++ /dev/null @@ -1,232 +0,0 @@ -use serde_json::json; -use terminator_mcp_agent::output_parser::{run_output_parser, OutputParserDefinition}; -use terminator_mcp_agent::utils::ExecuteSequenceArgs; - -#[tokio::test] -async fn test_legacy_output_parser_field() { - // Test that the legacy 'output_parser' field still works - let args_json = json!({ - "steps": [{ - "tool_name": "test_tool", - "arguments": {} - }], - "output_parser": { - "javascript_code": "return { success: true, data: 'legacy' };" - } - }); - - let args: ExecuteSequenceArgs = serde_json::from_value(args_json).unwrap(); - assert!(args.output_parser.is_some()); - assert!(args.output.is_none()); -} - -#[tokio::test] -async fn test_new_output_field() { - // Test that the new 'output' field works - let args_json = json!({ - "steps": [{ - "tool_name": "test_tool", - "arguments": {} - }], - "output": { - "javascript_code": "return { success: true, data: 'new' };" - } - }); - - let args: ExecuteSequenceArgs = serde_json::from_value(args_json).unwrap(); - assert!(args.output_parser.is_none()); - assert!(args.output.is_some()); -} - -#[tokio::test] -async fn test_output_field_with_run() { - // Test that the new 'output' field works with 'run' instead of 'javascript_code' - let args_json = json!({ - "steps": [{ - "tool_name": "test_tool", - "arguments": {} - }], - "output": { - "run": "return { success: true, data: 'run_syntax' };" - } - }); - - let args: ExecuteSequenceArgs = serde_json::from_value(args_json).unwrap(); - assert!(args.output.is_some()); -} - -#[tokio::test] -async fn test_output_field_as_string() { - // Test that the 'output' field can be a simple string (JavaScript code directly) - let args_json = json!({ - "steps": [{ - "tool_name": "test_tool", - "arguments": {} - }], - "output": "return { success: true, data: 'string_syntax' };" - }); - - let args: ExecuteSequenceArgs = serde_json::from_value(args_json).unwrap(); - assert!(args.output.is_some()); - assert!(args.output.as_ref().unwrap().is_string()); -} - -#[tokio::test] -async fn test_parser_definition_legacy_javascript_code() { - // Test legacy javascript_code field - let parser_json = json!({ - "javascript_code": "return { test: 'legacy' };" - }); - - let parser: OutputParserDefinition = serde_json::from_value(parser_json).unwrap(); - assert!(parser.javascript_code.is_some()); - assert_eq!( - parser.javascript_code.unwrap(), - "return { test: 'legacy' };" - ); - assert!(parser.run.is_none()); -} - -#[tokio::test] -async fn test_parser_definition_new_run_field() { - // Test new 'run' field - let parser_json = json!({ - "run": "return { test: 'new_run' };" - }); - - let parser: OutputParserDefinition = serde_json::from_value(parser_json).unwrap(); - assert!(parser.javascript_code.is_none()); - assert!(parser.run.is_some()); - assert_eq!(parser.run.unwrap(), "return { test: 'new_run' };"); -} - -#[tokio::test] -async fn test_parser_string_shorthand() { - // Test that run_output_parser handles string input - let parser_val = json!("return { success: true };"); - let tool_output = json!({ - "results": [] - }); - - // This should not error during parsing - the string shorthand is valid - // It will only error when trying to execute (Node.js not available in tests) - let result = run_output_parser(&parser_val, &tool_output).await; - // The result depends on whether Node.js is available in the test environment - // We just verify it doesn't panic and handles the string format correctly - match result { - Ok(_) => { - // Node.js was available and executed successfully - } - Err(err) => { - // Either Node.js wasn't available or the JS had an error - let err_msg = err.to_string(); - // Should be an execution error, not a parsing error - assert!( - err_msg.contains("Node.js") - || err_msg.contains("node") - || err_msg.contains("JavaScript") - ); - } - } -} - -#[tokio::test] -async fn test_parser_with_both_fields_errors() { - // Test that having both javascript_code and run fields is not allowed - let parser_json = json!({ - "javascript_code": "return 1;", - "run": "return 2;" - }); - - // This should fail to deserialize or cause an error when used - let parser: OutputParserDefinition = serde_json::from_value(parser_json).unwrap(); - assert!(parser.javascript_code.is_some()); - assert!(parser.run.is_some()); - - // When used, it should error because both fields are present - let tool_output = json!({}); - let parser_val = serde_json::to_value(&parser).unwrap(); - let result = run_output_parser(&parser_val, &tool_output).await; - - // The current implementation prioritizes javascript_code over run - // So it will attempt to execute with javascript_code - match result { - Ok(_) => { - // Node.js was available and executed the javascript_code field - } - Err(err) => { - let err_msg = err.to_string(); - // Could be either the "Cannot provide both" error or Node.js execution error - assert!( - err_msg.contains("Cannot provide both") - || err_msg.contains("Node.js") - || err_msg.contains("node") - || err_msg.contains("JavaScript") - ); - } - } -} - -#[tokio::test] -async fn test_backward_compatibility_full_workflow() { - // Test that old YAML format still works - let old_format = json!({ - "steps": [{ - "tool_name": "click_element", - "arguments": { - "selector": "button|Submit" - }, - "continue_on_error": false, - "delay_ms": 1000 - }], - "stop_on_error": true, - "include_detailed_results": true, - "output_parser": { - "javascript_code": "return { parsed: true };" - } - }); - - let args: ExecuteSequenceArgs = serde_json::from_value(old_format).unwrap(); - assert_eq!(args.stop_on_error, Some(true)); - assert_eq!(args.include_detailed_results, Some(true)); - assert!(args.output_parser.is_some()); - assert_eq!(args.steps.as_ref().unwrap()[0].delay_ms, Some(1000)); -} - -#[tokio::test] -async fn test_new_format_full_workflow() { - // Test that new simplified format works - let new_format = json!({ - "steps": [{ - "tool_name": "click_element", - "arguments": { - "selector": "button|Submit" - } - }], - "output": "return { parsed: true };" - }); - - let args: ExecuteSequenceArgs = serde_json::from_value(new_format).unwrap(); - assert!(args.output.is_some()); - assert!(args.output_parser.is_none()); -} - -#[test] -fn test_both_output_fields_together() { - // Test what happens when both output_parser and output are provided - // Should use output_parser for backward compatibility - let json_with_both = json!({ - "steps": [], - "output_parser": { - "javascript_code": "return 'old';" - }, - "output": { - "run": "return 'new';" - } - }); - - let args: ExecuteSequenceArgs = serde_json::from_value(json_with_both).unwrap(); - assert!(args.output_parser.is_some()); - assert!(args.output.is_some()); - // In the actual implementation, output_parser takes precedence -} diff --git a/crates/terminator-mcp-agent/tests/test_scripts_base_path.rs b/crates/terminator-mcp-agent/tests/test_scripts_base_path.rs deleted file mode 100644 index 135c0122c..000000000 --- a/crates/terminator-mcp-agent/tests/test_scripts_base_path.rs +++ /dev/null @@ -1,296 +0,0 @@ -use serde_json::json; -use std::fs; -use std::path::PathBuf; -use tempfile::TempDir; - -#[cfg(test)] -mod scripts_base_path_tests { - use super::*; - use terminator_mcp_agent::utils::ExecuteSequenceArgs; - - #[test] - fn test_scripts_base_path_serialization() { - // Test that scripts_base_path can be properly serialized/deserialized - let args = ExecuteSequenceArgs { - scripts_base_path: Some("/mnt/workflows/123".to_string()), - steps: Some(vec![]), - ..Default::default() - }; - - let serialized = serde_json::to_string(&args).unwrap(); - let deserialized: ExecuteSequenceArgs = serde_json::from_str(&serialized).unwrap(); - - assert_eq!( - deserialized.scripts_base_path, - Some("/mnt/workflows/123".to_string()) - ); - } - - #[test] - fn test_scripts_base_path_optional() { - // Test that scripts_base_path is optional and doesn't break existing workflows - let json_without_field = json!({ - "steps": [ - { - "tool_name": "run_command", - "arguments": { - "engine": "javascript", - "script_file": "test.js" - } - } - ] - }); - - let args: ExecuteSequenceArgs = serde_json::from_value(json_without_field).unwrap(); - assert_eq!(args.scripts_base_path, None); - } - - #[test] - fn test_workflow_yaml_with_scripts_base_path() { - // Test parsing YAML workflow with scripts_base_path - let yaml_content = r#" -name: Test Workflow -description: Test workflow with scripts_base_path -scripts_base_path: "/mnt/shared/scripts" -steps: - - tool_name: run_command - arguments: - engine: javascript - script_file: helper.js -"#; - - let parsed: ExecuteSequenceArgs = serde_yaml::from_str(yaml_content).unwrap(); - assert_eq!( - parsed.scripts_base_path, - Some("/mnt/shared/scripts".to_string()) - ); - assert!(parsed.steps.is_some()); - } - - #[test] - fn test_workflow_yaml_without_scripts_base_path() { - // Test backward compatibility - YAML without scripts_base_path should still work - let yaml_content = r#" -name: Legacy Workflow -description: Test workflow without scripts_base_path -steps: - - tool_name: run_command - arguments: - engine: javascript - script_file: test.js -"#; - - let parsed: ExecuteSequenceArgs = serde_yaml::from_str(yaml_content).unwrap(); - assert_eq!(parsed.scripts_base_path, None); - assert!(parsed.steps.is_some()); - } - - #[test] - fn test_scripts_base_path_with_all_fields() { - // Test that scripts_base_path works alongside all other ExecuteSequenceArgs fields - let args = ExecuteSequenceArgs { - url: Some("file://workflow.yml".to_string()), - steps: Some(vec![]), - troubleshooting: Some(vec![]), - variables: Some(std::collections::HashMap::new()), - inputs: Some(json!({})), - selectors: Some(json!({})), - stop_on_error: Some(true), - include_detailed_results: Some(false), - output_parser: Some(json!({})), - output: Some(json!({})), - r#continue: Some(false), - verbosity: Some("normal".to_string()), - start_from_step: Some("step1".to_string()), - end_at_step: Some("step5".to_string()), - follow_fallback: Some(false), - scripts_base_path: Some("/custom/path".to_string()), - execute_jumps_at_end: Some(false), - workflow_id: Some("test-workflow-123".to_string()), - skip_preflight_check: Some(false), - trace_id: Some("test-trace-123".to_string()), - execution_id: Some("test-execution-456".to_string()), - window_mgmt: Default::default(), - }; - - let serialized = serde_json::to_string(&args).unwrap(); - let deserialized: ExecuteSequenceArgs = serde_json::from_str(&serialized).unwrap(); - - assert_eq!( - deserialized.scripts_base_path, - Some("/custom/path".to_string()) - ); - assert_eq!(deserialized.url, Some("file://workflow.yml".to_string())); - assert_eq!(deserialized.stop_on_error, Some(true)); - } -} - -#[cfg(test)] -mod file_resolution_tests { - use super::*; - use std::fs; - use std::path::Path; - use tempfile::TempDir; - - fn setup_test_directories() -> (TempDir, PathBuf, PathBuf, PathBuf) { - let temp_dir = TempDir::new().unwrap(); - let base_path = temp_dir.path().to_path_buf(); - - // Create scripts_base_path directory - let scripts_base = base_path.join("scripts_base"); - fs::create_dir(&scripts_base).unwrap(); - - // Create workflow directory - let workflow_dir = base_path.join("workflow"); - fs::create_dir(&workflow_dir).unwrap(); - - // Create current directory (simulated) - let current_dir = base_path.join("current"); - fs::create_dir(¤t_dir).unwrap(); - - (temp_dir, scripts_base, workflow_dir, current_dir) - } - - #[test] - fn test_file_resolution_priority_scripts_base_first() { - let (_temp, scripts_base, workflow_dir, _current) = setup_test_directories(); - - // Create test.js in both scripts_base and workflow_dir - fs::write(scripts_base.join("test.js"), "// From scripts_base").unwrap(); - fs::write(workflow_dir.join("test.js"), "// From workflow_dir").unwrap(); - - // When scripts_base_path is set, it should be checked first - // The actual resolution logic would be in the server.rs file - // This test verifies the setup is correct - assert!(scripts_base.join("test.js").exists()); - assert!(workflow_dir.join("test.js").exists()); - } - - #[test] - fn test_file_resolution_fallback_to_workflow_dir() { - let (_temp, scripts_base, workflow_dir, _current) = setup_test_directories(); - - // Create test.js only in workflow_dir - fs::write(workflow_dir.join("test.js"), "// From workflow_dir").unwrap(); - - // File doesn't exist in scripts_base - assert!(!scripts_base.join("test.js").exists()); - // But exists in workflow_dir - assert!(workflow_dir.join("test.js").exists()); - } - - #[test] - fn test_absolute_path_unchanged() { - let (_temp, _scripts_base, _workflow_dir, _current) = setup_test_directories(); - - // Absolute paths should not be affected by scripts_base_path - let absolute_path = if cfg!(windows) { - "C:\\absolute\\path\\to\\script.js" - } else { - "/absolute/path/to/script.js" - }; - - let path = Path::new(absolute_path); - assert!(path.is_absolute()); - - // Even with scripts_base_path set, absolute paths should remain unchanged - // This is tested by the logic in server.rs - } - - #[test] - fn test_nested_relative_paths() { - let (_temp, scripts_base, _workflow_dir, _current) = setup_test_directories(); - - // Create nested structure in scripts_base - let nested = scripts_base.join("helpers").join("utils"); - fs::create_dir_all(&nested).unwrap(); - fs::write(nested.join("validator.js"), "// Validator script").unwrap(); - - // Verify nested path exists - assert!(scripts_base.join("helpers/utils/validator.js").exists()); - } - - #[test] - fn test_scripts_base_path_not_exist() { - // Test behavior when scripts_base_path points to non-existent directory - let non_existent = PathBuf::from("/this/does/not/exist"); - assert!(!non_existent.exists()); - - // The resolution logic should skip non-existent scripts_base_path - // and fall back to workflow_dir or current dir - // This is handled by the checks in server.rs - } -} - -#[cfg(test)] -mod integration_tests { - use super::*; - use terminator_mcp_agent::utils::ExecuteSequenceArgs; - - #[test] - fn test_workflow_with_mounted_storage_simulation() { - // Simulate the mounted storage scenario - let temp_dir = TempDir::new().unwrap(); - let mount_path = temp_dir.path().join("mnt").join("workflows").join("abc123"); - fs::create_dir_all(&mount_path).unwrap(); - - // Create helper scripts in mounted path - fs::write(mount_path.join("validator.js"), "// Validation logic").unwrap(); - fs::write(mount_path.join("processor.js"), "// Processing logic").unwrap(); - - // Create workflow that uses scripts_base_path - let workflow = ExecuteSequenceArgs { - scripts_base_path: Some(mount_path.to_string_lossy().to_string()), - steps: Some(vec![]), - ..Default::default() - }; - - assert!(workflow.scripts_base_path.is_some()); - let base_path = PathBuf::from(workflow.scripts_base_path.unwrap()); - assert!(base_path.join("validator.js").exists()); - assert!(base_path.join("processor.js").exists()); - } - - #[test] - fn test_backward_compatibility_no_regression() { - // Ensure old workflows without scripts_base_path continue to work - let legacy_workflow = r#" -steps: - - tool_name: run_command - arguments: - engine: javascript - script_file: local_script.js -variables: - api_key: - type: string - label: API Key -inputs: - api_key: "test-key-123" -"#; - - let parsed: ExecuteSequenceArgs = serde_yaml::from_str(legacy_workflow).unwrap(); - - // Should parse successfully without scripts_base_path - assert!(parsed.scripts_base_path.is_none()); - assert!(parsed.steps.is_some()); - assert!(parsed.variables.is_some()); - assert!(parsed.inputs.is_some()); - } - - #[test] - fn test_scripts_base_path_with_environment_variable_pattern() { - // Test that scripts_base_path can handle patterns like those from environment variables - let workflow = ExecuteSequenceArgs { - scripts_base_path: Some("${WORKFLOW_MOUNT_PATH}/scripts".to_string()), - steps: Some(vec![]), - ..Default::default() - }; - - // The actual environment variable substitution would happen at runtime - // This test just ensures the field accepts such patterns - assert_eq!( - workflow.scripts_base_path, - Some("${WORKFLOW_MOUNT_PATH}/scripts".to_string()) - ); - } -} diff --git a/crates/terminator-mcp-agent/tests/test_simplified_fields.rs b/crates/terminator-mcp-agent/tests/test_simplified_fields.rs deleted file mode 100644 index 4fe7ee321..000000000 --- a/crates/terminator-mcp-agent/tests/test_simplified_fields.rs +++ /dev/null @@ -1,206 +0,0 @@ -use serde_json::json; -use terminator_mcp_agent::duration_parser::parse_duration; -use terminator_mcp_agent::utils::{ExecuteSequenceArgs, SequenceStep}; - -#[test] -fn test_duration_parser() { - // Test milliseconds - assert_eq!(parse_duration("500").unwrap(), 500); - assert_eq!(parse_duration("1000ms").unwrap(), 1000); - assert_eq!(parse_duration("250milliseconds").unwrap(), 250); - - // Test seconds - assert_eq!(parse_duration("1s").unwrap(), 1000); - assert_eq!(parse_duration("2.5s").unwrap(), 2500); - assert_eq!(parse_duration("10seconds").unwrap(), 10000); - - // Test minutes - assert_eq!(parse_duration("1m").unwrap(), 60000); - assert_eq!(parse_duration("2min").unwrap(), 120000); - assert_eq!(parse_duration("0.5minutes").unwrap(), 30000); - - // Test hours - assert_eq!(parse_duration("1h").unwrap(), 3600000); - assert_eq!(parse_duration("2hours").unwrap(), 7200000); - assert_eq!(parse_duration("0.5h").unwrap(), 1800000); -} - -#[test] -fn test_continue_field() { - // Test new 'continue' field (opposite of stop_on_error) - let args_json = json!({ - "steps": [], - "continue": true - }); - - let args: ExecuteSequenceArgs = serde_json::from_value(args_json).unwrap(); - assert_eq!(args.r#continue, Some(true)); - assert!(args.stop_on_error.is_none()); -} - -#[test] -fn test_verbosity_field() { - // Test new 'verbosity' field - let args_quiet = json!({ - "steps": [], - "verbosity": "quiet" - }); - - let args: ExecuteSequenceArgs = serde_json::from_value(args_quiet).unwrap(); - assert_eq!(args.verbosity, Some("quiet".to_string())); - - let args_verbose = json!({ - "steps": [], - "verbosity": "verbose" - }); - - let args: ExecuteSequenceArgs = serde_json::from_value(args_verbose).unwrap(); - assert_eq!(args.verbosity, Some("verbose".to_string())); -} - -#[test] -fn test_delay_field_in_step() { - // Test new 'delay' field with human-readable duration - let step_json = json!({ - "tool_name": "wait_tool", - "arguments": {}, - "delay": "2s" - }); - - let step: SequenceStep = serde_json::from_value(step_json).unwrap(); - assert_eq!(step.delay, Some("2s".to_string())); - assert!(step.delay_ms.is_none()); -} - -#[test] -fn test_backward_compatibility_with_delay_ms() { - // Test that old delay_ms still works - let step_json = json!({ - "tool_name": "wait_tool", - "arguments": {}, - "delay_ms": 2000 - }); - - let step: SequenceStep = serde_json::from_value(step_json).unwrap(); - assert_eq!(step.delay_ms, Some(2000)); - assert!(step.delay.is_none()); -} - -#[test] -fn test_both_delay_fields() { - // Test what happens when both delay and delay_ms are provided - let step_json = json!({ - "tool_name": "wait_tool", - "arguments": {}, - "delay": "1s", - "delay_ms": 2000 - }); - - let step: SequenceStep = serde_json::from_value(step_json).unwrap(); - assert_eq!(step.delay, Some("1s".to_string())); - assert_eq!(step.delay_ms, Some(2000)); - // The implementation should prefer 'delay' over 'delay_ms' -} - -#[test] -fn test_both_continue_and_stop_on_error() { - // Test what happens when both fields are provided - let args_json = json!({ - "steps": [], - "continue": true, - "stop_on_error": false - }); - - let args: ExecuteSequenceArgs = serde_json::from_value(args_json).unwrap(); - assert_eq!(args.r#continue, Some(true)); - assert_eq!(args.stop_on_error, Some(false)); - // The implementation should prefer 'continue' over 'stop_on_error' -} - -#[test] -fn test_both_verbosity_and_include_detailed() { - // Test what happens when both fields are provided - let args_json = json!({ - "steps": [], - "verbosity": "quiet", - "include_detailed_results": true - }); - - let args: ExecuteSequenceArgs = serde_json::from_value(args_json).unwrap(); - assert_eq!(args.verbosity, Some("quiet".to_string())); - assert_eq!(args.include_detailed_results, Some(true)); - // The implementation should prefer 'verbosity' over 'include_detailed_results' -} - -#[test] -fn test_complex_workflow_with_all_new_fields() { - // Test a complete workflow using all new simplified fields - let workflow = json!({ - "steps": [ - { - "tool_name": "click_element", - "arguments": { - "selector": "button|Submit" - }, - "delay": "500ms" - }, - { - "tool_name": "type_text", - "arguments": { - "text": "Hello World" - }, - "delay": "1s" - } - ], - "continue": false, - "verbosity": "verbose", - "output": "return { success: true, data: context };" - }); - - let args: ExecuteSequenceArgs = serde_json::from_value(workflow).unwrap(); - assert_eq!(args.r#continue, Some(false)); - assert_eq!(args.verbosity, Some("verbose".to_string())); - assert!(args.output.is_some()); - assert_eq!(args.steps.as_ref().unwrap().len(), 2); - assert_eq!( - args.steps.as_ref().unwrap()[0].delay, - Some("500ms".to_string()) - ); - assert_eq!( - args.steps.as_ref().unwrap()[1].delay, - Some("1s".to_string()) - ); -} - -#[test] -fn test_mixed_old_and_new_syntax() { - // Test mixing old and new syntax in the same workflow - let workflow = json!({ - "steps": [ - { - "tool_name": "tool1", - "arguments": {}, - "delay_ms": 1000 // Old syntax - }, - { - "tool_name": "tool2", - "arguments": {}, - "delay": "2s" // New syntax - } - ], - "stop_on_error": true, // Old syntax - "output": { // New syntax - "run": "return { done: true };" - } - }); - - let args: ExecuteSequenceArgs = serde_json::from_value(workflow).unwrap(); - assert_eq!(args.stop_on_error, Some(true)); - assert!(args.r#continue.is_none()); - assert!(args.output.is_some()); - assert_eq!(args.steps.as_ref().unwrap()[0].delay_ms, Some(1000)); - assert_eq!( - args.steps.as_ref().unwrap()[1].delay, - Some("2s".to_string()) - ); -} diff --git a/crates/terminator-mcp-agent/tests/unit_tests.rs b/crates/terminator-mcp-agent/tests/unit_tests.rs index b67840f35..8e068403f 100644 --- a/crates/terminator-mcp-agent/tests/unit_tests.rs +++ b/crates/terminator-mcp-agent/tests/unit_tests.rs @@ -19,8 +19,6 @@ fn test_execute_sequence_args_serialization() { }]), stop_on_error: Some(false), include_detailed_results: Some(true), - output_parser: None, - output: None, r#continue: None, verbosity: None, variables: None, @@ -825,7 +823,7 @@ try { println!("๐Ÿงช Testing complete Node.js terminator.js execution..."); - let result = execute_javascript_with_nodejs(test_script.to_string(), None, None).await; + let result = execute_javascript_with_nodejs(test_script.to_string(), None, None, None, None, None).await; match result { Ok(value) => { diff --git a/examples/browser_dom_extraction.yml b/examples/browser_dom_extraction.yml deleted file mode 100644 index ae96e9a21..000000000 --- a/examples/browser_dom_extraction.yml +++ /dev/null @@ -1,318 +0,0 @@ ---- -# Browser DOM Extraction Examples -# This workflow demonstrates various patterns for extracting HTML DOM data using execute_browser_script -tool_name: execute_sequence -arguments: - variables: - test_url: - type: string - label: "URL to extract DOM from" - default: "https://example.com" - - inputs: - test_url: "https://example.com" - - steps: - # Step 1: Navigate to the target page - - tool_name: navigate_browser - arguments: - url: "${{inputs.test_url}}" - browser: "chrome" - delay_ms: 3000 - continue_on_error: false - - # Step 2: Get full HTML DOM (simple extraction) - - tool_name: execute_browser_script - arguments: - selector: "role:Window|name:Chrome" - script: | - // Simple full DOM extraction - document.documentElement.outerHTML - delay_ms: 500 - continue_on_error: true - step_id: full_dom_extraction - - # Step 3: Get structured page information with size management - - tool_name: execute_browser_script - arguments: - selector: "role:Window|name:Chrome" - script: | - // Structured extraction with truncation for large DOMs - const html = document.documentElement.outerHTML; - const maxLength = 30000; // MCP response size limit - - ({ - // Basic page info - url: window.location.href, - title: document.title, - description: document.querySelector('meta[name="description"]')?.content || '', - - // HTML with size management - html: html.length > maxLength - ? html.substring(0, maxLength) + '... [truncated]' - : html, - htmlLength: html.length, - wasTruncated: html.length > maxLength, - - // Text content preview - bodyText: document.body.innerText.substring(0, 500), - - // Page metrics - timestamp: new Date().toISOString() - }) - delay_ms: 500 - continue_on_error: false - step_id: structured_page_info - - # Step 4: Extract forms and input fields - - tool_name: execute_browser_script - arguments: - selector: "role:Window|name:Chrome" - script: | - // Extract all forms and their inputs - ({ - formCount: document.forms.length, - forms: Array.from(document.forms).map(form => ({ - id: form.id || null, - name: form.name || null, - action: form.action, - method: form.method.toUpperCase(), - target: form.target || '_self', - - // Extract all form inputs - inputs: Array.from(form.elements).map(element => ({ - tagName: element.tagName.toLowerCase(), - type: element.type || null, - name: element.name || null, - id: element.id || null, - required: element.required || false, - disabled: element.disabled || false, - // Redact sensitive values - value: element.type === 'password' ? '[REDACTED]' : - (element.value ? element.value.substring(0, 100) : ''), - placeholder: element.placeholder || null - })) - })), - - // Also get hidden inputs separately (useful for CSRF tokens, etc) - hiddenInputs: Array.from(document.querySelectorAll('input[type="hidden"]')).map(input => ({ - name: input.name, - value: input.value.substring(0, 100), // Truncate long values - form: input.form?.id || null - })) - }) - delay_ms: 500 - continue_on_error: false - step_id: form_extraction - - # Step 5: Extract metadata and SEO information - - tool_name: execute_browser_script - arguments: - selector: "role:Window|name:Chrome" - script: | - // Extract meta tags, Open Graph, and structured data - ({ - // Standard meta tags - metaTags: Array.from(document.querySelectorAll('meta')).map(meta => ({ - name: meta.name || null, - property: meta.getAttribute('property') || null, - content: (meta.content || '').substring(0, 200), - httpEquiv: meta.httpEquiv || null - })).filter(m => m.name || m.property), - - // Open Graph tags specifically - openGraph: Array.from(document.querySelectorAll('meta[property^="og:"]')).reduce((acc, meta) => { - const property = meta.getAttribute('property').replace('og:', ''); - acc[property] = meta.content; - return acc; - }, {}), - - // JSON-LD structured data - jsonLd: Array.from(document.querySelectorAll('script[type="application/ld+json"]')) - .map(script => { - try { - return JSON.parse(script.textContent); - } catch (e) { - return { error: 'Failed to parse JSON-LD', content: script.textContent.substring(0, 100) }; - } - }), - - // Page title and canonical URL - pageTitle: document.title, - canonical: document.querySelector('link[rel="canonical"]')?.href || null, - - // Language and charset - language: document.documentElement.lang || null, - charset: document.characterSet - }) - delay_ms: 500 - continue_on_error: false - step_id: metadata_extraction - - # Step 6: Analyze page structure and content - - tool_name: execute_browser_script - arguments: - selector: "role:Window|name:Chrome" - script: | - // Analyze page structure for content extraction - ({ - // Document statistics - statistics: { - totalElements: document.querySelectorAll('*').length, - forms: document.forms.length, - links: document.links.length, - images: document.images.length, - scripts: document.scripts.length, - stylesheets: document.styleSheets.length - }, - - // Heading structure (useful for content hierarchy) - headings: Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6')).map(h => ({ - level: parseInt(h.tagName.substring(1)), - text: h.innerText.substring(0, 100), - id: h.id || null, - className: h.className || null - })), - - // Links analysis - links: Array.from(document.links).slice(0, 50).map(link => ({ - text: link.innerText.substring(0, 50), - href: link.href, - target: link.target || '_self', - rel: link.rel || null, - isExternal: link.hostname !== window.location.hostname - })), - - // Images with alt text (accessibility check) - images: Array.from(document.images).slice(0, 20).map(img => ({ - src: img.src, - alt: img.alt || '[no alt text]', - width: img.naturalWidth, - height: img.naturalHeight, - loading: img.loading || 'auto' - })) - }) - delay_ms: 500 - continue_on_error: false - step_id: structure_analysis - - # Step 7: Extract clean text content without HTML - - tool_name: execute_browser_script - arguments: - selector: "role:Window|name:Chrome" - script: | - // Get clean, readable text content - // Clone document and remove unwanted elements - const clonedDoc = document.documentElement.cloneNode(true); - - // Remove script, style, and other non-content elements - const elementsToRemove = clonedDoc.querySelectorAll( - 'script, style, noscript, iframe, object, embed, [hidden], .hidden' - ); - elementsToRemove.forEach(el => el.remove()); - - // Get text content - const cleanText = clonedDoc.innerText || clonedDoc.textContent || ''; - - ({ - // Main content text - cleanText: cleanText.substring(0, 5000), - textLength: cleanText.length, - - // Extract specific content areas if they exist - mainContent: (() => { - const main = document.querySelector('main, [role="main"], article, .content, #content'); - return main ? main.innerText.substring(0, 2000) : null; - })(), - - // Navigation text - navigation: (() => { - const nav = document.querySelector('nav, [role="navigation"]'); - return nav ? nav.innerText.substring(0, 500) : null; - })(), - - // Footer content - footer: (() => { - const footer = document.querySelector('footer, [role="contentinfo"]'); - return footer ? footer.innerText.substring(0, 500) : null; - })(), - - // Word count estimate - wordCount: cleanText.split(/\s+/).filter(word => word.length > 0).length - }) - delay_ms: 500 - continue_on_error: false - step_id: clean_text_extraction - - # Step 8: Check for specific patterns or elements - - tool_name: execute_browser_script - arguments: - selector: "role:Window|name:Chrome" - script: | - // Check for specific patterns and elements - ({ - // Check for common frameworks/libraries - frameworks: { - jquery: typeof jQuery !== 'undefined' || typeof $ !== 'undefined', - react: document.querySelector('[data-reactroot], [data-react-root], #root') !== null, - angular: document.querySelector('[ng-app], [data-ng-app], .ng-scope') !== null, - vue: document.querySelector('[data-v-], #app.__vue__') !== null - }, - - // Check for authentication/user elements - authentication: { - loginForm: document.querySelector('form[action*="login"], form[action*="signin"], #loginForm') !== null, - logoutLink: document.querySelector('a[href*="logout"], a[href*="signout"]') !== null, - userMenu: document.querySelector('[class*="user-menu"], [class*="account"], [id*="user-menu"]') !== null - }, - - // Check for e-commerce elements - ecommerce: { - addToCart: document.querySelector('[class*="add-to-cart"], [id*="add-to-cart"], button[data-action="add-to-cart"]') !== null, - shoppingCart: document.querySelector('[class*="shopping-cart"], [class*="cart"], [id*="cart"]') !== null, - productPrice: document.querySelector('[class*="price"], [itemprop="price"], .price, .cost') !== null, - checkoutButton: document.querySelector('[href*="checkout"], button[class*="checkout"]') !== null - }, - - // Check for media elements - media: { - videos: document.querySelectorAll('video').length, - audios: document.querySelectorAll('audio').length, - iframes: document.querySelectorAll('iframe').length, - youtubeEmbeds: document.querySelectorAll('iframe[src*="youtube.com"], iframe[src*="youtu.be"]').length - }, - - // Check for tracking/analytics - analytics: { - googleAnalytics: typeof ga !== 'undefined' || typeof gtag !== 'undefined', - googleTagManager: document.querySelector('script[src*="googletagmanager.com"]') !== null, - facebookPixel: typeof fbq !== 'undefined' - } - }) - delay_ms: 500 - continue_on_error: false - step_id: pattern_detection - - stop_on_error: false - include_detailed_results: true - - # Output parser to summarize all extracted data - output_parser: - ui_tree_source_step_id: pattern_detection - javascript_code: | - // This parser would normally process the UI tree, but for DOM extraction - // we're primarily interested in the browser script results - // Return a summary of what was extracted - return { - summary: "DOM extraction completed successfully", - stepsCompleted: 8, - extractedData: [ - "Full HTML DOM", - "Structured page information", - "Forms and inputs", - "Metadata and SEO tags", - "Page structure analysis", - "Clean text content", - "Framework and pattern detection" - ] - }; \ No newline at end of file diff --git a/examples/comprehensive_ui_test.yml b/examples/comprehensive_ui_test.yml deleted file mode 100644 index 3836fb149..000000000 --- a/examples/comprehensive_ui_test.yml +++ /dev/null @@ -1,516 +0,0 @@ ---- -tool_name: execute_sequence -arguments: - steps: - # Step 1: Navigate to example.com - - tool_name: navigate_browser - id: navigate_blank - arguments: - url: "https://example.com" - browser_type: "Chrome" - delay_ms: 2000 - - # Step 2: Inject comprehensive test UI - - tool_name: execute_browser_script - id: inject_test_ui - arguments: - selector: "role:Window|name:Chrome" - script: | - (function() { - document.body.innerHTML = ` -

Terminator.js UI Element Test Page

-
-
Text Input:
-
Checkbox:
-
Range Slider: 50
-
Select Dropdown:
-
Radio Buttons:
-
Button: Clicks: 0
-
Textarea:
-
- `; - - // Add event listeners - document.getElementById('testSlider').addEventListener('input', (e) => { - document.getElementById('sliderValue').textContent = e.target.value; - }); - - let clicks = 0; - document.getElementById('testButton').addEventListener('click', () => { - clicks++; - document.getElementById('buttonClicks').textContent = 'Clicks: ' + clicks; - }); - - return JSON.stringify({ ui_created: true }); - })() - delay_ms: 100 # Wait for accessibility tree to update - - # Step 3: Set zoom to 50% - - tool_name: run_command - id: set_zoom_50 - arguments: - engine: javascript - run: | - await desktop.setZoom(50); - console.log('โœ“ Zoom set to 50%'); - return { zoom_set: true }; - delay_ms: 100 - - # Step 4: Highlight input field - - tool_name: run_command - id: highlight_input - arguments: - engine: javascript - run: | - // Find and highlight input field - const inputElements = await desktop.locator('role:edit').all(3000, 10); - if (inputElements.length > 0) { - const highlight = inputElements[0].highlight(0x00FF00, 3000, 'Test Input', 'TopLeft'); - console.log('โœ“ Input field highlighted'); - } - return { input_highlighted: true }; - delay_ms: 100 - - # Step 5: Test getValue() on text input - - tool_name: execute_browser_script - id: test_input_value - arguments: - selector: "role:Window|name:Chrome" - script: | - (function() { - const initialValue = document.getElementById('testInput').value; - console.log('โœ“ Initial input value:', initialValue); - return JSON.stringify({ input_initial_value: initialValue }); - })() - delay_ms: 100 - - # Step 6: Change input value - - tool_name: execute_browser_script - id: change_input_value - arguments: - selector: "role:Window|name:Chrome" - script: | - (function() { - document.getElementById('testInput').value = 'Changed via script'; - const updatedValue = document.getElementById('testInput').value; - console.log('โœ“ Updated input value:', updatedValue); - return JSON.stringify({ input_updated_value: updatedValue }); - })() - delay_ms: 100 - - # Step 7: Highlight checkbox - - tool_name: run_command - id: highlight_checkbox - arguments: - engine: javascript - run: | - // Find and highlight checkbox - const checkboxes = await desktop.locator('role:checkbox').all(3000, 10); - if (checkboxes.length > 0) { - const highlight = checkboxes[0].highlight(0x00FF00, 3000, 'Checkbox', 'TopRight'); - console.log('โœ“ Checkbox highlighted'); - } - return { checkbox_highlighted: true }; - delay_ms: 100 - - # Step 8: Test checkbox state - - tool_name: execute_browser_script - id: test_checkbox - arguments: - selector: "role:Window|name:Chrome" - script: | - (function() { - const initialState = document.getElementById('testCheckbox').checked; - console.log('โœ“ Initial checkbox state:', initialState); - - // Toggle to false - document.getElementById('testCheckbox').checked = false; - const uncheckedState = document.getElementById('testCheckbox').checked; - console.log('โœ“ Checkbox toggled to:', uncheckedState); - - // Toggle back to true - document.getElementById('testCheckbox').checked = true; - const checkedState = document.getElementById('testCheckbox').checked; - console.log('โœ“ Checkbox toggled back to:', checkedState); - - return JSON.stringify({ - checkbox_initial: initialState, - checkbox_final: checkedState - }); - })() - delay_ms: 100 - - # Step 9: Highlight slider - - tool_name: run_command - id: highlight_slider - arguments: - engine: javascript - run: | - // Find and highlight slider - const sliders = await desktop.locator('role:slider').all(3000, 10); - if (sliders.length > 0) { - const highlight = sliders[0].highlight(0x00FF00, 3000, 'Slider', 'BottomLeft'); - console.log('โœ“ Slider highlighted'); - } - return { slider_highlighted: true }; - delay_ms: 100 - - # Step 10: Test slider - - tool_name: execute_browser_script - id: test_slider - arguments: - selector: "role:Window|name:Chrome" - script: | - (function() { - const initialValue = document.getElementById('testSlider').value; - console.log('โœ“ Initial slider value:', initialValue); - - // Set to 75 - document.getElementById('testSlider').value = '75'; - document.getElementById('sliderValue').textContent = '75'; - const value75 = document.getElementById('testSlider').value; - console.log('โœ“ Slider updated to:', value75); - - // Set to 25 - document.getElementById('testSlider').value = '25'; - document.getElementById('sliderValue').textContent = '25'; - const value25 = document.getElementById('testSlider').value; - console.log('โœ“ Slider updated to:', value25); - - return JSON.stringify({ - slider_initial: initialValue, - slider_final: value25 - }); - })() - delay_ms: 100 - - # Step 11: Test radio buttons - - tool_name: execute_browser_script - id: test_radio_buttons - arguments: - selector: "role:Window|name:Chrome" - script: | - (function() { - const radio2State = document.getElementById('radio2').checked; - console.log('โœ“ Radio 2 checked:', radio2State); - - // Switch to radio 3 - document.getElementById('radio3').checked = true; - const radio3State = document.getElementById('radio3').checked; - const radio2StateAfter = document.getElementById('radio2').checked; - console.log('โœ“ Switched to Radio 3:', radio3State, '(Radio 2 now:', radio2StateAfter + ')'); - - return JSON.stringify({ - radio2_initial: radio2State, - radio3_final: radio3State, - radio2_final: radio2StateAfter - }); - })() - delay_ms: 100 - - # Step 12: Test select dropdown - - tool_name: execute_browser_script - id: test_dropdown - arguments: - selector: "role:Window|name:Chrome" - script: | - (function() { - const initialValue = document.getElementById('testSelect').value; - console.log('โœ“ Initial select value:', initialValue); - - // Change to opt3 - document.getElementById('testSelect').value = 'opt3'; - const updatedValue = document.getElementById('testSelect').value; - console.log('โœ“ Select changed to:', updatedValue); - - return JSON.stringify({ - select_initial: initialValue, - select_final: updatedValue - }); - })() - delay_ms: 100 - - # Step 13: Test textarea - - tool_name: execute_browser_script - id: test_textarea - arguments: - selector: "role:Window|name:Chrome" - script: | - (function() { - const initialValue = document.getElementById('testTextarea').value; - console.log('โœ“ Initial textarea value:', initialValue.substring(0, 30) + '...'); - - // Update textarea - document.getElementById('testTextarea').value = 'Updated textarea content\\nLine 2\\nLine 3'; - const updatedValue = document.getElementById('testTextarea').value; - console.log('โœ“ Updated textarea value:', updatedValue); - - return JSON.stringify({ - textarea_initial: initialValue, - textarea_final: updatedValue - }); - })() - delay_ms: 100 - - # Step 14: Highlight button - - tool_name: run_command - id: highlight_button - arguments: - engine: javascript - run: | - // Find and highlight button - const buttons = await desktop.locator('role:button').all(3000, 50); - console.log(`Found ${buttons.length} buttons, searching for test button...`); - - let testButton = null; - for (let i = 0; i < buttons.length; i++) { - const name = buttons[i].name(); - if (name && (name.includes('Test button') || name.includes('Click Me'))) { - testButton = buttons[i]; - console.log(`Found test button at index ${i}: "${name}"`); - break; - } - } - - if (testButton) { - const highlight = testButton.highlight(0x00FF00, 3000, 'Click Me Button', 'BottomRight'); - console.log('โœ“ Button highlighted'); - } - return { button_highlighted: true }; - delay_ms: 100 - - # Step 15: Test button clicks - - tool_name: execute_browser_script - id: test_button_clicks - arguments: - selector: "role:Window|name:Chrome" - script: | - (function() { - const initialClicks = document.getElementById('buttonClicks').textContent; - console.log('โœ“ Initial button clicks:', initialClicks); - - // Click once - document.getElementById('testButton').click(); - const clicks1 = document.getElementById('buttonClicks').textContent; - console.log('โœ“ After click:', clicks1); - - // Click twice more - document.getElementById('testButton').click(); - document.getElementById('testButton').click(); - const clicks3 = document.getElementById('buttonClicks').textContent; - console.log('โœ“ After 2 more clicks:', clicks3); - - return JSON.stringify({ - clicks_initial: initialClicks, - clicks_final: clicks3 - }); - })() - delay_ms: 100 - - # Step 16: Highlight input for keyboard test - - tool_name: run_command - id: highlight_keyboard_input - arguments: - engine: javascript - run: | - // Find and highlight input field - const inputElement = await desktop.locator('role:edit').first(2000); - const highlight = inputElement.highlight(0x00FF00, 3000, 'Keyboard Test', 'TopLeft'); - console.log('โœ“ Input field highlighted for keyboard test'); - return { keyboard_input_highlighted: true }; - delay_ms: 100 - - # Step 17: Test keyboard interaction with input - - tool_name: run_command - id: test_keyboard_input - arguments: - engine: javascript - run: | - // Click on the input field - const inputElement = await desktop.locator('role:edit').first(2000); - await inputElement.click(); - - // Select all and delete - await desktop.pressKey('{Ctrl}a'); - await desktop.pressKey('{Delete}'); - - // Type new text - await desktop.pressKey('Typed via pressKey'); - - console.log('โœ“ Keyboard input test completed'); - return { keyboard_test_complete: true }; - delay_ms: 100 - - # Step 18: Verify typed value - - tool_name: execute_browser_script - id: verify_typed_value - arguments: - selector: "role:Window|name:Chrome" - script: | - (function() { - const typedValue = document.getElementById('testInput').value; - console.log('โœ“ Value after pressKey():', typedValue); - return JSON.stringify({ typed_value: typedValue }); - })() - delay_ms: 100 - - # Step 19: Create scrollable content - - tool_name: execute_browser_script - id: create_scroll_content - arguments: - selector: "role:Window|name:Chrome" - script: | - (function() { - const scrollDiv = document.createElement('div'); - scrollDiv.innerHTML = '

Scroll Test

' + - '
' + - '
Bottom Element
'; - document.body.appendChild(scrollDiv); - - const scrollBefore = window.scrollY; - console.log('โœ“ Scroll position before:', scrollBefore); - return JSON.stringify({ scroll_before: scrollBefore }); - })() - delay_ms: 100 - - # Step 20: Scroll to bottom element - - tool_name: execute_browser_script - id: scroll_to_bottom - arguments: - selector: "role:Window|name:Chrome" - script: | - (function() { - document.getElementById('bottomElement').scrollIntoView({behavior: 'smooth'}); - return JSON.stringify({ scrolled: true }); - })() - delay_ms: 100 - - # Step 21: Verify scroll position - - tool_name: execute_browser_script - id: verify_scroll - arguments: - selector: "role:Window|name:Chrome" - script: | - (function() { - const scrollAfter = window.scrollY; - console.log('โœ“ Scroll position after scrollIntoView():', scrollAfter); - return JSON.stringify({ scroll_after: scrollAfter }); - })() - delay_ms: 100 - - # Step 22: Test validate() with delayed element - - tool_name: execute_browser_script - id: schedule_delayed_element - arguments: - selector: "role:Window|name:Chrome" - script: | - (function() { - setTimeout(() => { - const delayed = document.createElement('button'); - delayed.id = 'delayedButton'; - delayed.textContent = 'I appeared after 2 seconds!'; - delayed.setAttribute('aria-label', 'Delayed button'); - document.body.appendChild(delayed); - }, 2000); - return JSON.stringify({ scheduled: true }); - })() - delay_ms: 100 - - # Step 23: Validate before element appears - - tool_name: validate_element - id: validate_before - arguments: - selector: "role:button|name:I appeared" - timeout_ms: 500 - delay_ms: 100 # Wait for element to appear - - # Step 24: Validate after element appears - - tool_name: validate_element - id: validate_after - arguments: - selector: "role:button|name:I appeared" - timeout_ms: 1000 - delay_ms: 100 - - # Step 25: Test delay() timing accuracy - - tool_name: run_command - id: test_delay_timing - arguments: - engine: javascript - run: | - const timings = []; - for (let i = 0; i < 5; i++) { - const start = Date.now(); - await new Promise(resolve => setTimeout(resolve, 100)); - timings.push(Date.now() - start); - } - const avg = timings.reduce((a, b) => a + b) / timings.length; - console.log('โœ“ 5x delay(100ms) timings:', timings.map(t => t + 'ms').join(', ')); - console.log('โœ“ Average:', avg.toFixed(1) + 'ms (ยฑ' + (avg - 100).toFixed(1) + 'ms)'); - return { - timings: timings, - average: avg, - deviation: avg - 100 - }; - delay_ms: 100 - - # Step 26: Reset zoom to 100% - - tool_name: run_command - id: reset_zoom - arguments: - engine: javascript - run: | - await desktop.setZoom(100); - console.log('โœ“ Zoom reset to 100%'); - return { zoom_reset: true }; - delay_ms: 100 - - # Step 27: Close browser tab - - tool_name: run_command - id: close_tab - arguments: - engine: javascript - run: | - await desktop.pressKey('{Ctrl}w'); - console.log('โœ“ Browser tab closed'); - return { tab_closed: true }; - delay_ms: 100 - -# Output parser to summarize test results -output: | - const inputValue = (typeof input_updated_value !== 'undefined') ? input_updated_value : 'N/A'; - const checkboxFinal = (typeof checkbox_final !== 'undefined') ? checkbox_final : false; - const sliderFinal = (typeof slider_final !== 'undefined') ? slider_final : 'N/A'; - const selectFinal = (typeof select_final !== 'undefined') ? select_final : 'N/A'; - const clicksFinal = (typeof clicks_final !== 'undefined') ? clicks_final : 'N/A'; - const typedValue = (typeof typed_value !== 'undefined') ? typed_value : 'N/A'; - const scrollAfter = (typeof scroll_after !== 'undefined') ? scroll_after : 0; - const validateBeforeExists = (typeof validate_before_result !== 'undefined') ? validate_before_result.exists : false; - const validateAfterExists = (typeof validate_after_result !== 'undefined') ? validate_after_result.exists : false; - const avgTiming = (typeof average !== 'undefined') ? average : 0; - - return { - success: true, - message: 'Comprehensive UI Elements Test Completed Successfully', - data: { - input_test: { value: inputValue }, - checkbox_test: { final_state: checkboxFinal }, - slider_test: { final_value: sliderFinal }, - select_test: { final_value: selectFinal }, - button_test: { final_clicks: clicksFinal }, - keyboard_test: { typed_value: typedValue }, - scroll_test: { scroll_position: scrollAfter }, - validation_test: { - before_appears: validateBeforeExists, - after_appears: validateAfterExists - }, - timing_test: { average_ms: avgTiming } - } - }; - -workflow_info: - name: Comprehensive UI Elements Test - description: Tests all UI element types including input, checkbox, slider, radio buttons, dropdown, textarea, button, keyboard interaction, scrolling, and element validation - version: "1.0" - created: 2025-10-14T00:00:00.000Z - type: demo_test diff --git a/examples/enable_trailing_cursor.yml b/examples/enable_trailing_cursor.yml deleted file mode 100644 index 5ea9f5832..000000000 --- a/examples/enable_trailing_cursor.yml +++ /dev/null @@ -1,96 +0,0 @@ ---- -# Enable Trailing Cursor (Mouse Pointer Trails) in Windows Settings -# This workflow automates the process of enabling mouse pointer trails in Windows Accessibility settings - -tool_name: execute_sequence -arguments: - steps: - # Step 1: Open Windows Settings - - tool_name: open_application - arguments: - app_name: Settings - delay: "2s" # Wait for Settings to fully load - - # Step 2: Navigate to Accessibility section - - tool_name: click_element - arguments: - selector: "role:ListItem|name:Accessibility" - include_tree_after_action: false - delay: "1500ms" # Wait for page to load - - # Step 3: Navigate to Mouse pointer and touch settings - - tool_name: click_element - arguments: - selector: "role:ListItem|name:Mouse pointer and touch" - include_tree_after_action: false - delay: "1500ms" # Wait for settings page to load - - # Step 4: Check current toggle state of Mouse pointer trails - - tool_name: is_toggled - arguments: - selector: "role:Button|name:Mouse pointer trails" - include_tree_after_action: false - continue_on_error: true - id: check_trails_state - - # Step 5: Enable Mouse pointer trails only if currently disabled - # The is_toggled tool returns true if ON, false if OFF - - tool_name: set_toggled - arguments: - selector: "role:Button|name:Mouse pointer trails" - state: true # Set to enabled (true = ON) - include_tree_after_action: false - delay: "500ms" - - # Step 6: Optional - Adjust trail length using the slider - # Uncomment the following step if you want to set a specific trail length - # - tool_name: set_range_value - # arguments: - # selector: "role:Slider|name:Mouse pointer trails length" - # value: 10 # Set trail length (1-20 range typically) - # continue_on_error: true - - # Step 7: Optional - Show confirmation - - tool_name: highlight_element - arguments: - selector: "role:Button|name:Mouse pointer trails" - color: 65280 # Green color - duration_ms: 2000 - text: "Enabled!" - include_element_info: false - continue_on_error: true - - # Workflow configuration - stop_on_error: false # Continue even if some steps fail - verbosity: normal # Set to 'verbose' for detailed output - -# Workflow metadata -variables: - trail_length: - type: number - label: "Trail Length" - description: "Length of the mouse pointer trail (1-20)" - default: 7 - required: false - - highlight_result: - type: boolean - label: "Highlight Result" - description: "Show visual confirmation when enabled" - default: true - required: false - -# Alternative selectors for better compatibility across Windows versions -selectors: - accessibility_item: "role:ListItem|name:Accessibility" - mouse_settings: "role:ListItem|name:Mouse pointer and touch" - trails_toggle: "role:Button|name:Mouse pointer trails" - trails_slider: "role:Slider|name:Mouse pointer trails length" - settings_search: "role:Edit|name:Find a setting" - -# Usage notes: -# 1. This workflow requires Windows Settings app to be available -# 2. The user must have appropriate permissions to modify accessibility settings -# 3. The workflow will work on Windows 10/11 with standard Settings app -# 4. Trail length adjustment is optional and can be customized via variables -# 5. Visual highlighting at the end confirms successful enablement \ No newline at end of file diff --git a/examples/github_actions_style_commands.yml b/examples/github_actions_style_commands.yml deleted file mode 100644 index e5ee953a2..000000000 --- a/examples/github_actions_style_commands.yml +++ /dev/null @@ -1,90 +0,0 @@ ---- -tool_name: execute_sequence -arguments: - steps: - # Basic command execution - - tool_name: run_command - arguments: - run: echo "Hello from GitHub Actions-style syntax!" - - # Multi-line script - - tool_name: run_command - arguments: - run: | - echo "Starting deployment process..." - echo "Current directory: $(pwd)" - echo "Listing files:" - ls -la - echo "Deployment complete!" - - # Using PowerShell on Windows - - tool_name: run_command - arguments: - run: | - Write-Host "System Information:" - Get-ComputerInfo | Select-Object -Property CsName, OsName, OsVersion - shell: powershell - - # Python script execution - - tool_name: run_command - arguments: - run: | - import sys - import platform - print(f"Python {sys.version}") - print(f"Platform: {platform.system()}") - shell: python - - # Node.js script execution - - tool_name: run_command - arguments: - run: | - console.log('Node.js version:', process.version); - console.log('Platform:', process.platform); - shell: node - - # Working with specific directories - - tool_name: run_command - arguments: - run: npm list --depth=0 - working_directory: ./my-project - - # Checking git status - - tool_name: run_command - arguments: - run: | - git status - git branch --show-current - working_directory: ./ - - # File operations - - tool_name: run_command - arguments: - run: | - # Create a temporary file - echo "Test content" > temp_test.txt - - # Read the file - cat temp_test.txt - - # Clean up - rm temp_test.txt - shell: bash - - # Cross-platform compatible script - - tool_name: run_command - arguments: - run: | - echo "This works on any platform!" - echo "Current time: $(date)" - - # Chained commands with error handling - - tool_name: run_command - arguments: - run: | - echo "Step 1: Checking environment" && - echo "Step 2: Running tests" && - echo "Step 3: All tests passed!" - shell: bash - - stop_on_error: false diff --git a/examples/i94_automation.yml b/examples/i94_automation.yml deleted file mode 100644 index 1479e8689..000000000 --- a/examples/i94_automation.yml +++ /dev/null @@ -1,234 +0,0 @@ ---- -tool_name: execute_sequence -arguments: - variables: - first_name: - type: string - label: First (Given) Name - default: "Bob" - last_name: - type: string - label: Last (Family) Name/Surname - default: "Smith" - date_of_birth: - type: string - label: Date of Birth (MM/DD/YYYY) - default: "01/01/1990" - citizenship: - type: string - label: Country of Citizenship (3-letter code) - default: "USA" - document_number: - type: string - label: Document Number - default: "1234567890" - - inputs: - first_name: "Bob" - last_name: "Smith" - date_of_birth: "01/01/1990" - citizenship: "USA" - document_number: "1234567890" - - selectors: - terms_scroll: "name:You must scroll and read all of the text before you are allowed to click on agree below." - agree_button: "name:I ACKNOWLEDGE AND AGREE" - close_notification: "name:Click to close the Security Notification" - get_i94_button: "name:Get most recent I-94/I-95" - first_name_field: "name:Please enter your first name" - last_name_field: "name:Please enter your last name" - date_birth_field: "role:Edit|name:Date of Birth" - country_field: "role:ComboBox|name:Please enter your document number's country of issuance. You can begin to type the country's name, or you can enter the exact country 3 letter code. If the country is not found, you can type your country's 3 letter code and select Other." - document_field: "role:Edit|name:Please enter your document number" - submit_button: "name:Click to submit the form" - - steps: - - tool_name: open_application - arguments: - app_name: "chrome" - continue_on_error: true - - - tool_name: navigate_browser - arguments: - url: "https://i94.cbp.dhs.gov/search/recent-search" - include_tree_after_action: false - - - tool_name: wait_for_element - arguments: - selector: "${{ selectors.terms_scroll }}" - condition: "exists" - timeout_ms: 10000 - - # Use multiple page downs to scroll through EULA completely - - tool_name: press_key_global - arguments: - key: "{PageDown}" - delay_ms: 1000 - - - tool_name: press_key_global - arguments: - key: "{PageDown}" - delay_ms: 1000 - - - tool_name: press_key_global - arguments: - key: "{End}" - delay_ms: 2000 - - # Wait for agree button to become enabled - - tool_name: delay - arguments: - delay_ms: 3000 - - - tool_name: click_element - arguments: - selector: "${{ selectors.agree_button }}" - - - tool_name: delay - arguments: - delay_ms: 2000 - - # Close notification if present - - tool_name: click_element - arguments: - selector: "${{ selectors.close_notification }}" - continue_on_error: true - - - tool_name: delay - arguments: - delay_ms: 2000 - - # Navigate to the I-94 search form - THIS WAS THE MISSING STEP! - - tool_name: click_element - arguments: - selector: "${{ selectors.get_i94_button }}" - - - tool_name: delay - arguments: - delay_ms: 3000 - - # Wait for form to be available - - tool_name: wait_for_element - arguments: - selector: "${{ selectors.first_name_field }}" - condition: "exists" - timeout_ms: 15000 - - # Fill out the form - - tool_name: type_into_element - arguments: - selector: "${{ selectors.first_name_field }}" - text_to_type: "${{ inputs.first_name }}" - clear_before_typing: true - - - tool_name: type_into_element - arguments: - selector: "${{ selectors.last_name_field }}" - text_to_type: "${{ inputs.last_name }}" - clear_before_typing: true - - - tool_name: type_into_element - arguments: - selector: "${{ selectors.date_birth_field }}" - text_to_type: "${{ inputs.date_of_birth }}" - clear_before_typing: true - - - tool_name: type_into_element - arguments: - selector: "${{ selectors.country_field }}" - text_to_type: "${{ inputs.citizenship }}" - clear_before_typing: true - - - tool_name: delay - arguments: - delay_ms: 1000 - - - tool_name: press_key_global - arguments: - key: "{Enter}" - - - tool_name: delay - arguments: - delay_ms: 1000 - - - tool_name: type_into_element - arguments: - selector: "${{ selectors.document_field }}" - text_to_type: "${{ inputs.document_number }}" - clear_before_typing: true - - - tool_name: delay - arguments: - delay_ms: 2000 - - # Submit the form - - tool_name: click_element - arguments: - selector: "${{ selectors.submit_button }}" - - - tool_name: delay - arguments: - delay_ms: 5000 - - # Get focused window PID and capture results - - tool_name: get_applications_and_windows_list - id: "get_apps" - - - tool_name: run_command - engine: javascript - id: "extract_pid" - run: | - const apps = get_apps_result[0]?.applications || []; - const focused = apps.find(app => app.is_focused); - return { pid: focused?.pid || 0 }; - - # Get results page - - tool_name: get_window_tree - arguments: - pid: "{{extract_pid.pid}}" - - output_parser: - javascript_code: | - // Extract I-94 data from the UI tree - const data = {}; - - // Get all text elements from the UI tree - FIX: use `tree` not `uiTree` - const textElements = []; - - function collectTextElements(node) { - if (node.attributes && node.attributes.role === 'Text' && node.attributes.name) { - textElements.push(node.attributes.name); - } - if (node.children) { - for (const child of node.children) { - collectTextElements(child); - } - } - } - - collectTextElements(tree); - - // Helper function to get value that follows a label - function getValue(label) { - const labelIndex = textElements.findIndex(el => el.includes(label)); - return labelIndex >= 0 && labelIndex + 1 < textElements.length ? - textElements[labelIndex + 1] : null; - } - - // Extract I-94 information - data.admission_number = getValue('Admission I-94 Record Number:'); - data.arrival_date = getValue('Arrival/Issued Date:'); - data.class_of_admission = getValue('Class of Admission:'); - data.admit_until_date = getValue('Admit Until Date:'); - data.last_name = getValue('Last/Surname:'); - data.first_name = getValue('First (Given) Name:'); - data.birth_date = getValue('Birth Date:'); - data.document_number = getValue('Document Number:'); - data.country_of_citizenship = getValue('Country of Citizenship:'); - - // Set status based on whether we found data - data.status = data.admission_number ? "success" : "no_data_found"; - - return [data]; - - stop_on_error: false \ No newline at end of file diff --git a/examples/simple_browser_test.yml b/examples/simple_browser_test.yml deleted file mode 100644 index 8268bff62..000000000 --- a/examples/simple_browser_test.yml +++ /dev/null @@ -1,22 +0,0 @@ ---- -tool_name: execute_sequence -arguments: - variables: - test_url: - type: string - label: "URL to navigate to" - default: "https://example.com" - - steps: - # Step 1: Open browser to test page - - tool_name: navigate_browser - arguments: - url: "${{inputs.test_url}}" - browser: "chrome" - delay_ms: 2000 - - # Step 2: Take a screenshot - - tool_name: capture_element_screenshot - arguments: - selector: "role:Document" - continue_on_error: true diff --git a/examples/web_monitor_with_skip.yml b/examples/web_monitor_with_skip.yml deleted file mode 100644 index 579db24db..000000000 --- a/examples/web_monitor_with_skip.yml +++ /dev/null @@ -1,103 +0,0 @@ ---- -# Web monitoring workflow that skips if no changes detected -name: Website Change Monitor -description: Monitors a website for changes and triggers actions only when changes are detected - -# Run every 5 minutes -cron: "*/5 * * * *" - -variables: - target_url: - label: "Website URL to monitor" - default: "https://example.com" - -inputs: - target_url: "{{target_url}}" - -steps: - # Fetch current website content - - tool_name: run_command - arguments: - command: "curl -s '{{target_url}}' | md5sum | cut -d' ' -f1" - id: get_current_hash - - # Read previous hash from cache file (if exists) - - tool_name: run_command - arguments: - command: "test -f /tmp/web_monitor_hash.txt && cat /tmp/web_monitor_hash.txt || echo 'FIRST_RUN'" - id: get_previous_hash - - # Save current hash for next run - - tool_name: run_command - arguments: - command: "echo '{{get_current_hash.result}}' > /tmp/web_monitor_hash.txt" - id: save_hash - - # If changed, fetch full content for analysis - - tool_name: run_command - arguments: - command: "curl -s '{{target_url}}'" - id: fetch_content - condition: "{{get_previous_hash.result}} != {{get_current_hash.result}}" - -# Output parser to determine if workflow should skip -output_parser: - javascript_code: | - // Get the hash comparison results - const currentHashStep = sequenceResult.results?.find(r => r.step_id === 'get_current_hash'); - const previousHashStep = sequenceResult.results?.find(r => r.step_id === 'get_previous_hash'); - - const currentHash = currentHashStep?.result?.content?.[0]?.text?.trim() || ''; - const previousHash = previousHashStep?.result?.content?.[0]?.text?.trim() || ''; - - // Check if this is the first run - if (previousHash === 'FIRST_RUN') { - return { - skipped: false, - success: true, - message: "Initial baseline established for website monitoring", - data: { - action: "baseline_set", - hash: currentHash, - timestamp: new Date().toISOString() - } - }; - } - - // Check if content has changed - const hasChanged = currentHash !== previousHash; - - if (!hasChanged) { - // No changes detected - skip processing - return { - skipped: true, - success: false, - message: "No changes detected - monitoring skipped", - data: { - reason: "Website content unchanged since last check", - hash: currentHash, - checked_at: new Date().toISOString() - } - }; - } - - // Changes detected - trigger processing - const fetchStep = sequenceResult.results?.find(r => r.step_id === 'fetch_content'); - const contentFetched = fetchStep?.status === 'success'; - - return { - skipped: false, - success: contentFetched, - message: "Website changes detected and processed", - data: { - action: "changes_detected", - previous_hash: previousHash, - current_hash: currentHash, - content_fetched: contentFetched, - detected_at: new Date().toISOString() - }, - validation: { - change_detected: true, - monitoring_active: true - } - }; \ No newline at end of file diff --git a/examples/website_search_parser.yml b/examples/website_search_parser.yml deleted file mode 100644 index 28d260e93..000000000 --- a/examples/website_search_parser.yml +++ /dev/null @@ -1,179 +0,0 @@ ---- -# Example workflow that searches Google and extracts results -# Demonstrates the standardized success/failure pattern with output parser -tool_name: execute_sequence -arguments: - variables: - search_query: - type: string - label: Search Query - default: "Rust programming language" - required: true - - inputs: - search_query: "Rust programming language" - - selectors: - # Google-specific selectors - search_box: "role:ComboBox|name:Search" - search_button: "role:Button|name:Google Search" - - steps: - # Open browser and navigate to Google - - tool_name: open_application - arguments: - app_name: "chrome" - continue_on_error: true - delay_ms: 2000 - - - tool_name: navigate_browser - arguments: - url: "https://www.google.com" - include_tree_after_action: false - delay_ms: 3000 - - # Perform search - - tool_name: type_into_element - arguments: - selector: "${{ selectors.search_box }}" - text_to_type: "${{ inputs.search_query }}" - clear_before_typing: true - delay_ms: 500 - - - tool_name: press_key_global - arguments: - key: "{Enter}" - delay_ms: 3000 - - # Get focused window PID - - tool_name: get_applications_and_windows_list - id: "get_apps" - - - tool_name: run_command - engine: javascript - id: "extract_pid" - run: | - const apps = get_apps_result[0]?.applications || []; - const focused = apps.find(app => app.is_focused); - return { pid: focused?.pid || 0 }; - - # Capture search results page - - tool_name: get_window_tree - arguments: - pid: "{{extract_pid.pid}}" - id: "search_results" - - output_parser: - ui_tree_source_step_id: "search_results" - javascript_code: | - // Standard success/failure pattern for search results extraction - - // Initialize result structure - const results = []; - const errors = []; - - // Helper function to safely extract text - function getText(element) { - return element.attributes?.name || ''; - } - - // Check for error conditions first - function checkForErrors(element) { - const text = getText(element).toLowerCase(); - - // Common error indicators - if (text.includes('no results found') || - text.includes('did not match any documents') || - text.includes('error') && text.includes('occurred')) { - errors.push(text); - return true; - } - - // Recursively check children - if (element.children) { - for (const child of element.children) { - if (checkForErrors(child)) return true; - } - } - return false; - } - - // Extract search results - function extractResults(element, depth = 0) { - // Avoid infinite recursion - if (depth > 10) return; - - // Look for elements that appear to be search results - // Google typically uses specific patterns - if (element.attributes) { - const role = element.attributes.role; - const name = element.attributes.name; - - // Look for links that appear to be search results - if (role === 'Link' && name && - !name.toLowerCase().includes('google') && - !name.toLowerCase().includes('sign in') && - !name.toLowerCase().includes('settings') && - name.length > 10) { - - // Try to find associated description - let description = ''; - if (element.parent && element.parent.children) { - for (const sibling of element.parent.children) { - if (sibling.attributes?.role === 'Text' && - sibling.attributes?.name && - sibling !== element) { - description = sibling.attributes.name; - break; - } - } - } - - results.push({ - title: name, - url: element.attributes.href || 'No URL', - description: description || 'No description available' - }); - } - } - - // Recursively process children - if (element.children) { - for (const child of element.children) { - extractResults(child, depth + 1); - } - } - } - - // Main processing - const hasErrors = checkForErrors(tree); - - if (!hasErrors) { - extractResults(tree); - } - - // Determine success based on results - const success = results.length > 0 && errors.length === 0; - - // Build standardized response - const response = { - success: success, - data: results, - message: success - ? `Successfully extracted ${results.length} search results for "${inputs?.search_query || 'query'}"` - : errors.length > 0 - ? `Search failed: ${errors[0]}` - : "No search results found - page may not have loaded correctly", - error: errors.length > 0 ? errors.join('; ') : null, - validation: { - results_found: results.length, - has_errors: errors.length > 0, - page_loaded: tree.children && tree.children.length > 0, - search_query: inputs?.search_query || 'unknown' - } - }; - - return response; - - stop_on_error: false - include_detailed_results: false