use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use uuid::Uuid;
use super::types::{MessageEnqueueCallback, QueuedUserMessage};
#[derive(Debug, Clone)]
pub struct CmdResult {
pub success: bool,
pub code: i32,
pub output: String,
}
#[derive(Debug, Clone)]
pub struct RunningTask {
pub label: String,
pub started: std::time::Instant,
}
pub struct BackgroundTaskManager {
enqueue: MessageEnqueueCallback,
running: Mutex<HashMap<Uuid, Vec<RunningTask>>>,
}
impl BackgroundTaskManager {
pub fn new(enqueue: MessageEnqueueCallback) -> Self {
Self {
enqueue,
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.mark_started(session_id, &label);
let this = std::sync::Arc::clone(&self);
let task_id = Uuid::new_v4();
tokio::spawn(async move {
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 result = run_detached(&command, &cwd).await;
tracing::info!(
target: "background_task",
"Background task '{label}' for session {session_id} finished (success={})",
result.success
);
let msg = completion_message(&label, &command, &result);
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.enqueue)(session_id, msg);
this.mark_finished(session_id, &label);
});
}
}
fn task_repo() -> Option<crate::db::BackgroundTaskRepository> {
crate::db::global_pool().map(|p| crate::db::BackgroundTaskRepository::new(p.clone()))
}
pub async fn report_interrupted(enqueue: &MessageEnqueueCallback) -> usize {
let Some(repo) = task_repo() else {
return 0;
};
let rows = match repo.all().await {
Ok(rows) => rows,
Err(e) => {
tracing::error!(target: "background_task", "Failed to read background tasks: {e:#}");
return 0;
}
};
if rows.is_empty() {
return 0;
}
let count = rows.len();
for row in rows {
tracing::warn!(
target: "background_task",
"Background task '{}' for session {} was interrupted by a restart",
row.label,
row.session_id
);
enqueue(row.session_id, interrupted_message(&row));
}
if let Err(e) = repo.clear_all().await {
tracing::error!(
target: "background_task",
"Failed to clear background tasks after reporting: {e:#}"
);
}
count
}
fn interrupted_message(row: &crate::db::BackgroundTaskRow) -> QueuedUserMessage {
let context_text = format!(
"[BACKGROUND TASK INTERRUPTED] `{}` was still running when OpenCrabs restarted, so it \
was killed and produced no result. The command was:\n\n```\n{}\n```\n\nIt did NOT \
complete. Decide whether to run it again based on what you were doing; do not assume \
it passed or failed.",
row.label, row.command
);
QueuedUserMessage {
context_text,
display_text: format!("⚠️ Background task interrupted by restart: {}", row.label),
}
}
async fn run_detached(command: &str, cwd: &std::path::Path) -> CmdResult {
use tokio::process::Command;
let output = Command::new("sh")
.arg("-c")
.arg(command)
.current_dir(cwd)
.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) => CmdResult {
success: false,
code: -1,
output: format!("failed to launch: {e}"),
},
}
}
const KNOWN_LONG_MARKERS: &[&str] = &[
"cargo test",
"cargo build",
"cargo run",
"cargo clippy",
"npm test",
"npm run build",
"pnpm test",
"pnpm build",
"yarn test",
"yarn build",
"npx remotion render",
"remotion render",
"gh run watch",
"gh pr checks --watch",
];
pub(crate) fn is_known_long(command: &str) -> bool {
let lower = command.to_lowercase();
KNOWN_LONG_MARKERS.iter().any(|m| lower.contains(m))
}
pub(crate) fn short_label(command: &str) -> String {
let trimmed = command.trim();
let after_cd = trimmed.rsplit("&&").next().unwrap_or(trimmed).trim();
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,
) -> 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" }
);
QueuedUserMessage::system(context, display)
}