use crate::output::TaskOutputManager; use crate::types::*; use std::collections::HashMap; use std::path::PathBuf; use tokio::io::AsyncBufReadExt; use tokio::process::Command; use tokio::sync::RwLock; #[derive(Debug, thiserror::Error)] pub enum TaskError { #[error("Task '{0}' not found")] NotFound(String), #[error("Task '{0}' is already running")] AlreadyRunning(String), #[error("Task '{0}' is not running")] NotRunning(String), #[error("Failed to spawn process: {0}")] SpawnFailed(String), #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("Task name '{0}' already exists")] DuplicateName(String), } struct RunningTask { abort_handles: Vec>, child: tokio::process::Child, lifecycle: Option>, } /// The main task runner service. /// All methods take &self — uses interior mutability for shared access. pub struct TaskRunner { definitions: RwLock>, statuses: RwLock>, started_at: RwLock>>, stopped_at: RwLock>>, running: RwLock>, tasks_dir: PathBuf, default_working_dir: PathBuf, process_manager: Option>, } impl TaskRunner { pub fn new( tasks_dir: PathBuf, default_working_dir: PathBuf, process_manager: Option>, ) -> Self { Self { definitions: RwLock::new(HashMap::new()), statuses: RwLock::new(HashMap::new()), started_at: RwLock::new(HashMap::new()), stopped_at: RwLock::new(HashMap::new()), running: RwLock::new(HashMap::new()), tasks_dir, default_working_dir, process_manager, } } pub fn tasks_dir(&self) -> &PathBuf { &self.tasks_dir } /// Register a task definition (does not start it). /// Allows re-registration to update an existing task. pub async fn register(&self, def: TaskDefinition) -> Result<(), TaskError> { let name = def.name.clone(); self.definitions.write().await.insert(name.clone(), def); self.statuses .write() .await .entry(name) .or_insert(TaskStatus::Stopped); Ok(()) } /// Remove a task definition (stops it if running) pub async fn remove(&self, name: &str) -> Result<(), TaskError> { if self.is_running(name).await { self.stop(name).await?; } self.definitions.write().await.remove(name); self.statuses.write().await.remove(name); self.started_at.write().await.remove(name); self.stopped_at.write().await.remove(name); Ok(()) } pub async fn is_running(&self, name: &str) -> bool { matches!( self.statuses.read().await.get(name), Some(TaskStatus::Running { .. }) ) } /// Start a task by name pub async fn start(&self, name: &str) -> Result<(), TaskError> { let def = { let defs = self.definitions.read().await; defs.get(name) .cloned() .ok_or_else(|| TaskError::NotFound(name.to_string()))? }; if self.is_running(name).await { return Err(TaskError::AlreadyRunning(name.to_string())); } let output_mgr = TaskOutputManager::new(self.tasks_dir.join(&def.name)); output_mgr.ensure_dir().await?; if def.rotate_previous { if let Err(e) = output_mgr.rotate().await { tracing::warn!("Failed to rotate output for task {}: {}", name, e); } } // Resolve working directory: explicit > default > current process dir let raw_cwd = def .working_directory .clone() .unwrap_or_else(|| self.default_working_dir.clone()); // Canonicalize to an absolute path; fall back to current dir if invalid let cwd = match std::fs::canonicalize(&raw_cwd) { Ok(p) => p, Err(_) => { tracing::warn!( "Task '{}': working directory '{}' invalid, falling back to current dir", name, raw_cwd.display() ); std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) } }; let lifecycle = self.process_manager.as_ref().map(|mgr| mgr.create_lifecycle()); let mut cmd = Command::new(&def.command); cmd.args(&def.args) .current_dir(&cwd) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .kill_on_drop(true); if let Some(ref lc) = lifecycle { lc.configure_async_command(&mut cmd); } for (key, value) in &def.env { cmd.env(key, value); } let mut child = match cmd.spawn() { Ok(child) => child, Err(e) => { let error_msg = format!("{} (cwd: {}): {}", def.command, cwd.display(), e); // Write error to stderr.log and combined.log so the user can see it in the output viewer let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ"); let log_line = format!("[{}] Failed to start: {}\n", timestamp, error_msg); if def.persist_to_disk { let _ = tokio::fs::write(output_mgr.stderr_path(), log_line.as_bytes()).await; let _ = tokio::fs::write(output_mgr.combined_path(), format!("[stderr] {}", log_line).as_bytes()).await; } // Set status to Failed so the UI shows it self.statuses.write().await.insert(name.to_string(), TaskStatus::Failed { error: error_msg.clone() }); self.stopped_at.write().await.insert(name.to_string(), chrono::Utc::now()); return Err(TaskError::SpawnFailed(error_msg)); } }; let pid = child.id().unwrap_or(0); tracing::info!("Task '{}' started with PID {} (cwd: {})", name, pid, cwd.display()); if let Some(ref lc) = lifecycle { if let Some(child_pid) = child.id() { if let Err(e) = lc.register_child(child_pid) { tracing::warn!(error = %e, "Failed to register task child with process lifecycle"); } } } let stdout = child.stdout.take(); let stderr = child.stderr.take(); let persist = def.persist_to_disk; let mut abort_handles = Vec::new(); // When not rotating, truncate old logs so we don't accumulate output across restarts let truncate = !def.rotate_previous; if truncate && persist { let _ = tokio::fs::write(output_mgr.stdout_path(), b"").await; let _ = tokio::fs::write(output_mgr.stderr_path(), b"").await; let _ = tokio::fs::write(output_mgr.combined_path(), b"").await; } // Stdout capture task if let Some(stdout) = stdout { let stdout_path = output_mgr.stdout_path(); let combined_path = output_mgr.combined_path(); let task_name = name.to_string(); let h = tokio::spawn(async move { let reader = tokio::io::BufReader::new(stdout); let mut lines = reader.lines(); let mut stdout_file = if persist { tokio::fs::OpenOptions::new() .create(true) .append(true) .open(&stdout_path) .await .ok() } else { None }; let mut combined_file = if persist { tokio::fs::OpenOptions::new() .create(true) .append(true) .open(&combined_path) .await .ok() } else { None }; while let Ok(Some(line)) = lines.next_line().await { let ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ"); if let Some(ref mut f) = stdout_file { let _ = tokio::io::AsyncWriteExt::write_all( f, format!("[{}] {}\n", ts, line).as_bytes(), ) .await; } if let Some(ref mut f) = combined_file { let _ = tokio::io::AsyncWriteExt::write_all( f, format!("[{}] [stdout] {}\n", ts, line).as_bytes(), ) .await; } } tracing::debug!("Stdout capture ended for task '{}'", task_name); }); abort_handles.push(h); } // Stderr capture task if let Some(stderr) = stderr { let stderr_path = output_mgr.stderr_path(); let combined_path = output_mgr.combined_path(); let task_name = name.to_string(); let h = tokio::spawn(async move { let reader = tokio::io::BufReader::new(stderr); let mut lines = reader.lines(); let mut stderr_file = if persist { tokio::fs::OpenOptions::new() .create(true) .append(true) .open(&stderr_path) .await .ok() } else { None }; let mut combined_file = if persist { tokio::fs::OpenOptions::new() .create(true) .append(true) .open(&combined_path) .await .ok() } else { None }; while let Ok(Some(line)) = lines.next_line().await { let ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ"); if let Some(ref mut f) = stderr_file { let _ = tokio::io::AsyncWriteExt::write_all( f, format!("[{}] {}\n", ts, line).as_bytes(), ) .await; } if let Some(ref mut f) = combined_file { let _ = tokio::io::AsyncWriteExt::write_all( f, format!("[{}] [stderr] {}\n", ts, line).as_bytes(), ) .await; } } tracing::debug!("Stderr capture ended for task '{}'", task_name); }); abort_handles.push(h); } self.statuses .write() .await .insert(name.to_string(), TaskStatus::Running { pid }); self.started_at .write() .await .insert(name.to_string(), chrono::Utc::now()); self.stopped_at.write().await.remove(name); self.running.write().await.insert( name.to_string(), RunningTask { abort_handles, child, lifecycle, }, ); Ok(()) } /// Stop a running task pub async fn stop(&self, name: &str) -> Result<(), TaskError> { if !self.is_running(name).await { return Err(TaskError::NotRunning(name.to_string())); } let mut running = self.running.write().await; if let Some(mut task) = running.remove(name) { if let Some(ref lifecycle) = task.lifecycle { dirigent_process::graceful_shutdown_async( lifecycle.as_ref(), &mut task.child, std::time::Duration::from_secs(3), ) .await; } else { let _ = task.child.kill().await; } for h in task.abort_handles { h.abort(); } tracing::info!("Task '{}' stopped", name); } self.statuses .write() .await .insert(name.to_string(), TaskStatus::Stopped); self.stopped_at .write() .await .insert(name.to_string(), chrono::Utc::now()); Ok(()) } /// Poll running tasks for completion (call periodically from a timer) pub async fn poll_completed(&self) { let mut running = self.running.write().await; let mut completed = Vec::new(); for (name, task) in running.iter_mut() { match task.child.try_wait() { Ok(Some(status)) => { let exit_code = status.code(); tracing::info!( "Task '{}' finished with exit code: {:?}", name, exit_code ); completed.push((name.clone(), exit_code)); } Ok(None) => {} Err(e) => { tracing::error!("Error checking task '{}': {}", name, e); completed.push((name.clone(), None)); } } } let mut statuses = self.statuses.write().await; let mut stopped_at = self.stopped_at.write().await; for (name, exit_code) in completed { running.remove(&name); statuses.insert(name.clone(), TaskStatus::Finished { exit_code }); stopped_at.insert(name.clone(), chrono::Utc::now()); } } /// List all tasks with their info pub async fn list_tasks(&self) -> Vec { let defs = self.definitions.read().await; let statuses = self.statuses.read().await; let started = self.started_at.read().await; let stopped = self.stopped_at.read().await; defs.values() .map(|def| TaskInfo { definition: def.clone(), status: statuses .get(&def.name) .cloned() .unwrap_or(TaskStatus::Stopped), started_at: started.get(&def.name).cloned(), stopped_at: stopped.get(&def.name).cloned(), }) .collect() } /// Get info for a specific task pub async fn get_task(&self, name: &str) -> Option { let defs = self.definitions.read().await; let def = defs.get(name)?; let statuses = self.statuses.read().await; let started = self.started_at.read().await; let stopped = self.stopped_at.read().await; Some(TaskInfo { definition: def.clone(), status: statuses .get(name) .cloned() .unwrap_or(TaskStatus::Stopped), started_at: started.get(name).cloned(), stopped_at: stopped.get(name).cloned(), }) } /// Read output for a task pub async fn read_output( &self, name: &str, kind: OutputKind, tail_lines: Option, ) -> Result { { let defs = self.definitions.read().await; if !defs.contains_key(name) { return Err(TaskError::NotFound(name.to_string())); } } let mgr = TaskOutputManager::new(self.tasks_dir.join(name)); mgr.read_output(kind, tail_lines).await.map_err(TaskError::Io) } /// Get all task definitions (for config persistence) pub async fn get_definitions(&self) -> Vec { self.definitions.read().await.values().cloned().collect() } /// Update a task definition (stops if running, re-registers) pub async fn update(&self, def: TaskDefinition) -> Result<(), TaskError> { let name = def.name.clone(); if self.is_running(&name).await { self.stop(&name).await?; } self.register(def).await } /// Stop all running tasks. Used during graceful shutdown. pub async fn stop_all(&self) { let names: Vec = self.running.read().await.keys().cloned().collect(); for name in names { if let Err(e) = self.stop(&name).await { tracing::warn!(task = %name, error = %e, "Failed to stop task during shutdown"); } } } }