[wip] single runtime handling

This commit is contained in:
2026-03-10 09:30:57 -05:00
parent 9e7e35cbe3
commit 5b45b17fa6
43 changed files with 2905 additions and 110 deletions

View File

@@ -1,6 +1,6 @@
//! Local Runtime Module
//!
//! Provides local execution capabilities by combining Process and Shell runtimes.
//! Provides local execution capabilities by combining Process and Native runtimes.
//! This module serves as a facade for all local process-based execution.
//!
//! The `ProcessRuntime` is used for Python (and other interpreted languages),
@@ -8,10 +8,11 @@
use super::native::NativeRuntime;
use super::process::ProcessRuntime;
use super::shell::ShellRuntime;
use super::{ExecutionContext, ExecutionResult, Runtime, RuntimeError, RuntimeResult};
use async_trait::async_trait;
use attune_common::models::runtime::{InterpreterConfig, RuntimeExecutionConfig};
use attune_common::models::runtime::{
InlineExecutionConfig, InlineExecutionStrategy, InterpreterConfig, RuntimeExecutionConfig,
};
use std::path::PathBuf;
use tracing::{debug, info};
@@ -19,7 +20,7 @@ use tracing::{debug, info};
pub struct LocalRuntime {
native: NativeRuntime,
python: ProcessRuntime,
shell: ShellRuntime,
shell: ProcessRuntime,
}
impl LocalRuntime {
@@ -34,6 +35,23 @@ impl LocalRuntime {
args: vec![],
file_extension: Some(".py".to_string()),
},
inline_execution: InlineExecutionConfig::default(),
environment: None,
dependencies: None,
env_vars: std::collections::HashMap::new(),
};
let shell_config = RuntimeExecutionConfig {
interpreter: InterpreterConfig {
binary: "/bin/bash".to_string(),
args: vec![],
file_extension: Some(".sh".to_string()),
},
inline_execution: InlineExecutionConfig {
strategy: InlineExecutionStrategy::TempFile,
extension: Some(".sh".to_string()),
inject_shell_helpers: true,
},
environment: None,
dependencies: None,
env_vars: std::collections::HashMap::new(),
@@ -47,7 +65,12 @@ impl LocalRuntime {
PathBuf::from("/opt/attune/packs"),
PathBuf::from("/opt/attune/runtime_envs"),
),
shell: ShellRuntime::new(),
shell: ProcessRuntime::new(
"shell".to_string(),
shell_config,
PathBuf::from("/opt/attune/packs"),
PathBuf::from("/opt/attune/runtime_envs"),
),
}
}
@@ -55,7 +78,7 @@ impl LocalRuntime {
pub fn with_runtimes(
native: NativeRuntime,
python: ProcessRuntime,
shell: ShellRuntime,
shell: ProcessRuntime,
) -> Self {
Self {
native,
@@ -76,7 +99,10 @@ impl LocalRuntime {
);
Ok(&self.python)
} else if self.shell.can_execute(context) {
debug!("Selected Shell runtime for action: {}", context.action_ref);
debug!(
"Selected Shell (ProcessRuntime) for action: {}",
context.action_ref
);
Ok(&self.shell)
} else {
Err(RuntimeError::RuntimeNotFound(format!(

View File

@@ -159,9 +159,9 @@ pub struct ExecutionContext {
/// Format for output parsing
pub output_format: OutputFormat,
/// Optional cancellation token for graceful process termination.
/// When triggered, the executor sends SIGINT → SIGTERM → SIGKILL
/// with escalating grace periods.
/// Optional cancellation token for process termination.
/// When triggered, the executor sends SIGTERM → SIGKILL
/// with a short grace period.
pub cancel_token: Option<CancellationToken>,
}

View File

@@ -19,12 +19,18 @@ use super::{
process_executor, ExecutionContext, ExecutionResult, Runtime, RuntimeError, RuntimeResult,
};
use async_trait::async_trait;
use attune_common::models::runtime::{EnvironmentConfig, RuntimeExecutionConfig};
use attune_common::models::runtime::{
EnvironmentConfig, InlineExecutionStrategy, RuntimeExecutionConfig,
};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::process::Command;
use tracing::{debug, error, info, warn};
fn bash_single_quote_escape(s: &str) -> String {
s.replace('\'', "'\\''")
}
/// A generic runtime driven by `RuntimeExecutionConfig` from the database.
///
/// Each `ProcessRuntime` instance corresponds to a row in the `runtime` table.
@@ -437,6 +443,90 @@ impl ProcessRuntime {
pub fn config(&self) -> &RuntimeExecutionConfig {
&self.config
}
fn build_shell_inline_wrapper(
&self,
merged_parameters: &HashMap<String, serde_json::Value>,
code: &str,
) -> RuntimeResult<String> {
let mut script = String::new();
script.push_str("#!/bin/bash\n");
script.push_str("set -e\n\n");
script.push_str("# Action parameters\n");
for (key, value) in merged_parameters {
let value_str = match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
_ => serde_json::to_string(value)?,
};
let escaped = bash_single_quote_escape(&value_str);
script.push_str(&format!(
"export PARAM_{}='{}'\n",
key.to_uppercase(),
escaped
));
script.push_str(&format!("export {}='{}'\n", key, escaped));
}
script.push('\n');
script.push_str("# Action code\n");
script.push_str(code);
Ok(script)
}
async fn materialize_inline_code(
&self,
execution_id: i64,
merged_parameters: &HashMap<String, serde_json::Value>,
code: &str,
effective_config: &RuntimeExecutionConfig,
) -> RuntimeResult<(PathBuf, bool)> {
let inline_dir = std::env::temp_dir().join("attune").join("inline_actions");
tokio::fs::create_dir_all(&inline_dir).await.map_err(|e| {
RuntimeError::ExecutionFailed(format!(
"Failed to create inline action directory {}: {}",
inline_dir.display(),
e
))
})?;
let extension = effective_config
.inline_execution
.extension
.as_deref()
.unwrap_or("");
let extension = if extension.is_empty() {
String::new()
} else if extension.starts_with('.') {
extension.to_string()
} else {
format!(".{}", extension)
};
let inline_path = inline_dir.join(format!("exec_{}{}", execution_id, extension));
let inline_code = if effective_config.inline_execution.inject_shell_helpers {
self.build_shell_inline_wrapper(merged_parameters, code)?
} else {
code.to_string()
};
tokio::fs::write(&inline_path, inline_code)
.await
.map_err(|e| {
RuntimeError::ExecutionFailed(format!(
"Failed to write inline action file {}: {}",
inline_path.display(),
e
))
})?;
Ok((
inline_path,
effective_config.inline_execution.inject_shell_helpers,
))
}
}
#[async_trait]
@@ -661,7 +751,7 @@ impl Runtime for ProcessRuntime {
};
let prepared_params =
parameter_passing::prepare_parameters(&merged_parameters, &mut env, param_config)?;
let parameters_stdin = prepared_params.stdin_content();
let mut parameters_stdin = prepared_params.stdin_content();
// Determine working directory: use context override, or pack dir
let working_dir = context
@@ -677,6 +767,7 @@ impl Runtime for ProcessRuntime {
});
// Build the command based on whether we have a file or inline code
let mut temp_inline_file: Option<PathBuf> = None;
let cmd = if let Some(ref code_path) = context.code_path {
// File-based execution: interpreter [args] <action_file>
debug!("Executing file: {}", code_path.display());
@@ -688,13 +779,38 @@ impl Runtime for ProcessRuntime {
&env,
)
} else if let Some(ref code) = context.code {
// Inline code execution: interpreter -c <code>
debug!("Executing inline code ({} bytes)", code.len());
let mut cmd = process_executor::build_inline_command(&interpreter, code, &env);
if let Some(dir) = working_dir {
cmd.current_dir(dir);
match effective_config.inline_execution.strategy {
InlineExecutionStrategy::Direct => {
debug!("Executing inline code directly ({} bytes)", code.len());
let mut cmd = process_executor::build_inline_command(&interpreter, code, &env);
if let Some(dir) = working_dir {
cmd.current_dir(dir);
}
cmd
}
InlineExecutionStrategy::TempFile => {
debug!("Executing inline code via temp file ({} bytes)", code.len());
let (inline_path, consumes_parameters) = self
.materialize_inline_code(
context.execution_id,
&merged_parameters,
code,
effective_config,
)
.await?;
if consumes_parameters {
parameters_stdin = None;
}
temp_inline_file = Some(inline_path.clone());
process_executor::build_action_command(
&interpreter,
&effective_config.interpreter.args,
&inline_path,
working_dir,
&env,
)
}
}
cmd
} else {
// No code_path and no inline code — try treating entry_point as a file
// relative to the pack's actions directory
@@ -737,7 +853,7 @@ impl Runtime for ProcessRuntime {
// Execute with streaming output capture (with optional cancellation support).
// Secrets are already merged into parameters — no separate secrets arg needed.
process_executor::execute_streaming_cancellable(
let result = process_executor::execute_streaming_cancellable(
cmd,
&HashMap::new(),
parameters_stdin,
@@ -747,7 +863,13 @@ impl Runtime for ProcessRuntime {
context.output_format,
context.cancel_token.clone(),
)
.await
.await;
if let Some(path) = temp_inline_file {
let _ = tokio::fs::remove_file(path).await;
}
result
}
async fn setup(&self) -> RuntimeResult<()> {
@@ -836,7 +958,8 @@ impl Runtime for ProcessRuntime {
mod tests {
use super::*;
use attune_common::models::runtime::{
DependencyConfig, EnvironmentConfig, InterpreterConfig, RuntimeExecutionConfig,
DependencyConfig, EnvironmentConfig, InlineExecutionConfig, InlineExecutionStrategy,
InterpreterConfig, RuntimeExecutionConfig,
};
use attune_common::models::{OutputFormat, ParameterDelivery, ParameterFormat};
use std::collections::HashMap;
@@ -849,6 +972,11 @@ mod tests {
args: vec![],
file_extension: Some(".sh".to_string()),
},
inline_execution: InlineExecutionConfig {
strategy: InlineExecutionStrategy::TempFile,
extension: Some(".sh".to_string()),
inject_shell_helpers: true,
},
environment: None,
dependencies: None,
env_vars: HashMap::new(),
@@ -862,6 +990,7 @@ mod tests {
args: vec!["-u".to_string()],
file_extension: Some(".py".to_string()),
},
inline_execution: InlineExecutionConfig::default(),
environment: Some(EnvironmentConfig {
env_type: "virtualenv".to_string(),
dir_name: ".venv".to_string(),
@@ -1104,6 +1233,7 @@ mod tests {
args: vec![],
file_extension: Some(".py".to_string()),
},
inline_execution: InlineExecutionConfig::default(),
environment: None,
dependencies: None,
env_vars: HashMap::new(),
@@ -1183,6 +1313,53 @@ mod tests {
assert!(result.stdout.contains("inline shell code"));
}
#[tokio::test]
async fn test_execute_inline_code_with_merged_inputs() {
let temp_dir = TempDir::new().unwrap();
let runtime = ProcessRuntime::new(
"shell".to_string(),
make_shell_config(),
temp_dir.path().to_path_buf(),
temp_dir.path().join("runtime_envs"),
);
let context = ExecutionContext {
execution_id: 30,
action_ref: "adhoc.test_inputs".to_string(),
parameters: {
let mut map = HashMap::new();
map.insert("name".to_string(), serde_json::json!("Alice"));
map
},
env: HashMap::new(),
secrets: {
let mut map = HashMap::new();
map.insert("api_key".to_string(), serde_json::json!("secret-123"));
map
},
timeout: Some(10),
working_dir: None,
entry_point: "inline".to_string(),
code: Some("echo \"$name/$api_key/$PARAM_NAME/$PARAM_API_KEY\"".to_string()),
code_path: None,
runtime_name: Some("shell".to_string()),
runtime_config_override: None,
runtime_env_dir_suffix: None,
selected_runtime_version: None,
max_stdout_bytes: 1024 * 1024,
max_stderr_bytes: 1024 * 1024,
parameter_delivery: ParameterDelivery::default(),
parameter_format: ParameterFormat::default(),
output_format: OutputFormat::default(),
cancel_token: None,
};
let result = runtime.execute(context).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("Alice/secret-123/Alice/secret-123"));
}
#[tokio::test]
async fn test_execute_entry_point_fallback() {
let temp_dir = TempDir::new().unwrap();

View File

@@ -9,9 +9,8 @@
//!
//! When a `CancellationToken` is provided, the executor monitors it alongside
//! the running process. On cancellation:
//! 1. SIGINT is sent to the process (allows graceful shutdown)
//! 2. After a 10-second grace period, SIGTERM is sent if the process hasn't exited
//! 3. After another 5-second grace period, SIGKILL is sent as a last resort
//! 1. SIGTERM is sent to the process immediately
//! 2. After a 5-second grace period, SIGKILL is sent as a last resort
use super::{BoundedLogWriter, ExecutionResult, OutputFormat, RuntimeResult};
use std::collections::HashMap;
@@ -71,7 +70,7 @@ pub async fn execute_streaming(
/// - Writing parameters (with secrets merged in) to stdin
/// - Streaming stdout/stderr with bounded log collection
/// - Timeout management
/// - Graceful cancellation via SIGINT → SIGTERM → SIGKILL escalation
/// - Prompt cancellation via SIGTERM → SIGKILL escalation
/// - Output format parsing (JSON, YAML, JSONL, text)
///
/// # Arguments
@@ -199,35 +198,23 @@ pub async fn execute_streaming_cancellable(
tokio::select! {
result = timed_wait => (result, false),
_ = token.cancelled() => {
// Cancellation requested — escalate signals to the child process.
info!("Cancel signal received, sending SIGINT to process");
// Cancellation requested — terminate the child process promptly.
info!("Cancel signal received, sending SIGTERM to process");
if let Some(pid) = child_pid {
send_signal(pid, libc::SIGINT);
send_signal(pid, libc::SIGTERM);
}
// Grace period: wait up to 10s for the process to exit after SIGINT.
match timeout(std::time::Duration::from_secs(10), child.wait()).await {
// Grace period: wait up to 5s for the process to exit after SIGTERM.
match timeout(std::time::Duration::from_secs(5), child.wait()).await {
Ok(status) => (Ok(status), true),
Err(_) => {
// Still alive — escalate to SIGTERM
warn!("Process did not exit after SIGINT + 10s grace period, sending SIGTERM");
// Last resort — SIGKILL
warn!("Process did not exit after SIGTERM + 5s, sending SIGKILL");
if let Some(pid) = child_pid {
send_signal(pid, libc::SIGTERM);
}
// Final grace period: wait up to 5s for SIGTERM
match timeout(std::time::Duration::from_secs(5), child.wait()).await {
Ok(status) => (Ok(status), true),
Err(_) => {
// Last resort — SIGKILL
warn!("Process did not exit after SIGTERM + 5s, sending SIGKILL");
if let Some(pid) = child_pid {
send_signal(pid, libc::SIGKILL);
}
// Wait indefinitely for the SIGKILL to take effect
(Ok(child.wait().await), true)
}
send_signal(pid, libc::SIGKILL);
}
// Wait indefinitely for the SIGKILL to take effect
(Ok(child.wait().await), true)
}
}
}