use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use uuid::Uuid;
use super::types::{BgTaskMeta, PushOrigin, QueuedUserMessage};
#[derive(Debug, Clone)]
pub struct CmdResult {
pub success: bool,
pub code: i32,
pub output: String,
}
pub struct HookContext {
pub result: CmdResult,
pub elapsed_secs: f32,
}
pub type CompletionHook = Box<
dyn FnOnce(HookContext) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>
+ Send,
>;
#[derive(Debug, Clone)]
pub struct RunningTask {
pub label: String,
pub started: std::time::Instant,
}
pub struct BackgroundTaskManager {
running: Mutex<HashMap<Uuid, Vec<RunningTask>>>,
}
use super::work_status::{CommandExit, WorkStatus};
impl BackgroundTaskManager {
pub fn new() -> Self {
Self {
running: Mutex::new(HashMap::new()),
}
}
pub fn running_for(&self, session_id: Uuid) -> usize {
self.running
.lock()
.map(|m| m.get(&session_id).map(Vec::len).unwrap_or(0))
.unwrap_or(0)
}
pub fn running_tasks(&self, session_id: Uuid) -> Vec<RunningTask> {
self.running
.lock()
.map(|m| m.get(&session_id).cloned().unwrap_or_default())
.unwrap_or_default()
}
fn mark_started(&self, session_id: Uuid, label: &str) {
if let Ok(mut m) = self.running.lock() {
m.entry(session_id).or_default().push(RunningTask {
label: label.to_string(),
started: std::time::Instant::now(),
});
}
}
fn mark_finished(&self, session_id: Uuid, label: &str) {
if let Ok(mut m) = self.running.lock()
&& let Some(tasks) = m.get_mut(&session_id)
{
if let Some(pos) = tasks.iter().position(|t| t.label == label) {
tasks.remove(pos);
}
if tasks.is_empty() {
m.remove(&session_id);
}
}
}
pub fn spawn_command(
self: std::sync::Arc<Self>,
session_id: Uuid,
cwd: PathBuf,
label: String,
command: String,
) {
self.spawn_inner(session_id, cwd, label, command, None);
}
pub fn spawn_command_with_hook(
self: std::sync::Arc<Self>,
session_id: Uuid,
cwd: PathBuf,
label: String,
command: String,
hook: CompletionHook,
) {
self.spawn_inner(session_id, cwd, label, command, Some(hook));
}
fn spawn_inner(
self: std::sync::Arc<Self>,
session_id: Uuid,
cwd: PathBuf,
label: String,
command: String,
hook: Option<CompletionHook>,
) {
self.mark_started(session_id, &label);
let this = std::sync::Arc::clone(&self);
let task_id = Uuid::new_v4();
if let Err(e) = WorkStatus::new_command(
&task_id.to_string(),
&session_id.to_string(),
&label,
&command,
) {
tracing::warn!(
target: "background_task",
"Could not write detached status for {task_id}: {e}"
);
}
tokio::spawn(async move {
tracing::info!(
target: "background_task",
"Background task '{label}' started for session {session_id} \
(id={task_id}, cwd={})",
cwd.display()
);
if let Some(repo) = task_repo() {
let cwd_str = cwd.to_string_lossy().to_string();
if let Err(e) = repo
.record(task_id, session_id, &label, &command, &cwd_str)
.await
{
tracing::error!(
target: "background_task",
"Failed to persist background task '{label}': {e:#}"
);
}
}
let started = std::time::Instant::now();
let result = run_detached(&command, &cwd, session_id).await;
let elapsed_secs = started.elapsed().as_secs_f32();
tracing::info!(
target: "background_task",
"Background task '{label}' for session {session_id} finished \
(success={}, exit={}, elapsed={:.1}s)",
result.success,
result.code,
elapsed_secs
);
if let Err(e) = WorkStatus::finish_command(
&task_id.to_string(),
&session_id.to_string(),
&label,
&command,
CommandExit {
success: result.success,
code: result.code,
elapsed_secs,
output_bytes: result.output.len(),
},
) {
tracing::warn!(
target: "background_task",
"Could not write detached status for {task_id}: {e}"
);
}
if let Some(repo) = task_repo()
&& let Err(e) = repo.clear(task_id).await
{
tracing::error!(
target: "background_task",
"Failed to clear background task '{label}' after completion: {e:#}"
);
}
this.mark_finished(session_id, &label);
if let Some(hook) = hook {
hook(HookContext {
result,
elapsed_secs,
})
.await;
return;
}
let msg = completion_message(&label, &command, &result, elapsed_secs);
let outcome = super::session_routes::deliver_to_session(session_id, msg, true);
match outcome {
super::session_routes::Delivery::Redirected { to } => {
tracing::info!(
target: "background_task",
"Background task '{label}' completion for session {session_id} was \
redirected to session {to}, which now owns its channel"
);
}
super::session_routes::Delivery::Parked => {
tracing::info!(
target: "background_task",
"Background task '{label}' completion for session {session_id} is \
parked until its channel claims the session"
);
}
super::session_routes::Delivery::NoRoute => {
tracing::warn!(
target: "background_task",
"Background task '{label}' completion for session {session_id} had \
nowhere to go; the session will not hear about it"
);
}
super::session_routes::Delivery::RefusedInFlight { .. } => {
tracing::warn!(
target: "background_task",
"Background task '{label}' completion for session {session_id} was \
refused by the mid-turn gate despite interrupt=true"
);
}
super::session_routes::Delivery::Delivered => {}
}
});
}
}
pub(super) fn task_repo() -> Option<crate::db::BackgroundTaskRepository> {
crate::db::global_pool().map(|p| crate::db::BackgroundTaskRepository::new(p.clone()))
}
async fn run_detached(command: &str, cwd: &std::path::Path, session_id: Uuid) -> CmdResult {
use crate::utils::shell::PushShellCommand;
use tokio::process::Command;
let (shell, shell_arg) = crate::utils::shell::shell_pair();
let output = Command::new(shell)
.push_shell_command(shell_arg, command)
.current_dir(cwd)
.env("OPENCRABS_SESSION_ID", session_id.to_string())
.output()
.await;
match output {
Ok(out) => {
let mut combined = String::from_utf8_lossy(&out.stdout).into_owned();
let err = String::from_utf8_lossy(&out.stderr);
if !err.trim().is_empty() {
if !combined.is_empty() {
combined.push('\n');
}
combined.push_str(&err);
}
CmdResult {
success: out.status.success(),
code: out.status.code().unwrap_or(-1),
output: combined,
}
}
Err(e) => {
tracing::error!(
target: "background_task",
"Background command could not be launched in {}: {e}",
cwd.display()
);
CmdResult {
success: false,
code: -1,
output: format!("failed to launch: {e}"),
}
}
}
}
pub(crate) fn short_label(command: &str) -> String {
let after_cd = crate::utils::command_label::command_label(command);
let label: String = after_cd.chars().take(60).collect();
if after_cd.chars().count() > 60 {
format!("{label}…")
} else {
label
}
}
pub(crate) fn tail_lines(text: &str, n: usize) -> String {
let lines: Vec<&str> = text.lines().collect();
let start = lines.len().saturating_sub(n);
lines[start..].join("\n")
}
pub(crate) fn completion_message(
label: &str,
command: &str,
result: &CmdResult,
elapsed_secs: f32,
) -> QueuedUserMessage {
let status = if result.success {
"exit 0 (success)".to_string()
} else {
format!("exit {} (failure)", result.code)
};
let tail = tail_lines(&result.output, 50);
let context = format!(
"[System: the background task you started has finished.\n\
Task: {label}\n\
Command: {command}\n\
Status: {status}\n\
Output (last 50 lines):\n{tail}\n\n\
Report the result to the user and continue anything that was waiting on it. \
Do not re-run the command — this IS its result.]"
);
let display = format!(
"🔧 background task {}: {label}",
if result.success { "finished" } else { "failed" }
);
let mut msg = QueuedUserMessage::system(context, display);
msg.origin = PushOrigin::BackgroundTask;
msg.bg_meta = Some(BgTaskMeta {
success: result.success,
label: label.to_string(),
elapsed_secs,
tail,
});
msg
}
pub(crate) fn format_elapsed(secs: f32) -> String {
let total = secs.max(0.0).round() as u64;
if total < 60 {
format!("{total}s")
} else if total < 3600 {
format!("{}m {}s", total / 60, total % 60)
} else {
format!("{}h {}m", total / 3600, (total % 3600) / 60)
}
}