Skip to main content

atman_runtime/tools/
bash_bg.rs

1use std::collections::HashMap;
2use std::process::Stdio;
3use std::sync::{Arc, Mutex};
4use std::time::Duration;
5
6use command_group::{AsyncCommandGroup, AsyncGroupChild};
7use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
8use tokio::sync::mpsc;
9use tokio_util::sync::CancellationToken;
10
11use crate::error::RuntimeError;
12use crate::task_registry::{TaskKind, TaskRegistry, TaskStatus};
13use crate::tool::{BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
14use crate::value::Value;
15
16const DEFAULT_SPAWN_TIMEOUT_MS: u64 = 1_800_000;
17const MAX_SPAWN_TIMEOUT_MS: u64 = 86_400_000;
18const DEFAULT_MAX_OUTPUT_BYTES: u64 = 10_485_760;
19const RING_BUFFER_BYTES: usize = 65_536;
20const IO_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
21const DEFAULT_OUTPUT_LIMIT: usize = 32_000;
22
23#[derive(Debug, Clone, Hash, PartialEq, Eq)]
24pub struct BgHandle {
25    session_id: String,
26    local_id: u64,
27}
28
29impl BgHandle {
30    #[allow(clippy::inherent_to_string)]
31    pub fn to_string(&self) -> String {
32        format!("bg_{}_{}", self.session_id, self.local_id)
33    }
34
35    pub fn parse(s: &str) -> Option<Self> {
36        let rest = s.strip_prefix("bg_")?;
37        let idx = rest.rfind('_')?;
38        let session_id = rest[..idx].to_string();
39        let local_id = rest[idx + 1..].parse().ok()?;
40        Some(Self {
41            session_id,
42            local_id,
43        })
44    }
45}
46
47#[derive(Debug, Clone)]
48pub enum BgStatus {
49    Running {
50        pid: u32,
51        started_at: i64,
52    },
53    Exited {
54        exit_code: i32,
55        started_at: i64,
56        ended_at: i64,
57    },
58    TimedOut {
59        started_at: i64,
60        ended_at: i64,
61    },
62    Killed {
63        started_at: i64,
64        ended_at: i64,
65    },
66    Failed {
67        error: String,
68        started_at: i64,
69        ended_at: i64,
70    },
71}
72
73impl BgStatus {
74    fn kind(&self) -> &'static str {
75        match self {
76            Self::Running { .. } => "running",
77            Self::Exited { .. } => "exited",
78            Self::TimedOut { .. } => "timed_out",
79            Self::Killed { .. } => "killed",
80            Self::Failed { .. } => "failed",
81        }
82    }
83
84    fn exit_code(&self) -> Option<i32> {
85        match self {
86            Self::Exited { exit_code, .. } => Some(*exit_code),
87            _ => None,
88        }
89    }
90
91    fn started_at(&self) -> i64 {
92        match self {
93            Self::Running { started_at, .. }
94            | Self::Exited { started_at, .. }
95            | Self::TimedOut { started_at, .. }
96            | Self::Killed { started_at, .. }
97            | Self::Failed { started_at, .. } => *started_at,
98        }
99    }
100
101    fn ended_at(&self) -> Option<i64> {
102        match self {
103            Self::Exited { ended_at, .. }
104            | Self::TimedOut { ended_at, .. }
105            | Self::Killed { ended_at, .. }
106            | Self::Failed { ended_at, .. } => Some(*ended_at),
107            _ => None,
108        }
109    }
110
111    fn is_finished(&self) -> bool {
112        !matches!(self, Self::Running { .. })
113    }
114}
115
116#[derive(Debug, Default)]
117pub struct BgOutput {
118    pub combined: Vec<u8>,
119    pub total_bytes: u64,
120    pub truncated: bool,
121}
122
123impl BgOutput {
124    fn push(&mut self, kind: StreamKind, data: &[u8], max: u64) {
125        let prefix: &[u8] = match kind {
126            StreamKind::Stdout => b"[out] ",
127            StreamKind::Stderr => b"[err] ",
128        };
129        let mut new_total = self.total_bytes + data.len() as u64;
130        let mut to_write = data;
131        if new_total > max {
132            let allowed = max.saturating_sub(self.total_bytes) as usize;
133            to_write = &data[..allowed.min(data.len())];
134            new_total = max;
135            self.truncated = true;
136        }
137        if !to_write.is_empty() {
138            self.combined.extend_from_slice(prefix);
139            self.combined.extend_from_slice(to_write);
140            if !to_write.ends_with(b"\n") {
141                self.combined.push(b'\n');
142            }
143        }
144        self.total_bytes = new_total;
145        let max_ring = RING_BUFFER_BYTES;
146        if self.combined.len() > max_ring {
147            let drop = self.combined.len() - max_ring;
148            self.combined.drain(..drop);
149        }
150    }
151
152    fn read_from(&self, cursor: usize, limit: usize) -> (Vec<u8>, usize, bool) {
153        let data = &self.combined;
154        if cursor >= data.len() {
155            return (Vec::new(), data.len(), true);
156        }
157        let remaining = &data[cursor..];
158        let take = remaining.len().min(limit);
159        let chunk = remaining[..take].to_vec();
160        let next = cursor + take;
161        let eof = next >= data.len();
162        (chunk, next, eof)
163    }
164}
165
166#[derive(Clone, Copy)]
167enum StreamKind {
168    Stdout,
169    Stderr,
170}
171
172pub(crate) enum BgControl {
173    Kill,
174}
175
176pub struct BgEntry {
177    pub session_id: String,
178    pub(crate) control_tx: mpsc::Sender<BgControl>,
179    pub status: Arc<Mutex<BgStatus>>,
180    pub output: Arc<Mutex<BgOutput>>,
181    pub log_path: std::path::PathBuf,
182    pub task_id: Option<crate::task_registry::TaskId>,
183}
184
185impl crate::watch::Watchable for BgEntry {
186    fn watch_output(
187        self: std::sync::Arc<Self>,
188        pattern: String,
189        cancel: tokio_util::sync::CancellationToken,
190    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::watch::WatchResult> + Send>>
191    {
192        let output = self.output.clone();
193        let status = self.status.clone();
194        Box::pin(async move {
195            loop {
196                tokio::select! {
197                    _ = cancel.cancelled() => return crate::watch::WatchResult::Cancelled,
198                    _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {
199                        let text = {
200                            let out = output.lock().unwrap();
201                            String::from_utf8_lossy(&out.combined).into_owned()
202                        };
203                        if let Some(pos) = text.find(&pattern) {
204                            return crate::watch::WatchResult::Matched {
205                                row: None,
206                                col: Some(pos as u16),
207                                text: pattern,
208                            };
209                        }
210                        let st = status.lock().unwrap().clone();
211                        if !matches!(st, BgStatus::Running { .. }) {
212                            return crate::watch::WatchResult::SourceExited;
213                        }
214                    }
215                }
216            }
217        })
218    }
219}
220
221#[derive(Default)]
222pub struct BgRegistry {
223    entries: Mutex<HashMap<String, Arc<BgEntry>>>,
224    task_registry: Option<TaskRegistry>,
225}
226
227impl BgRegistry {
228    pub fn new() -> Self {
229        Self::default()
230    }
231
232    pub fn with_task_registry(mut self, tr: TaskRegistry) -> Self {
233        self.task_registry = Some(tr);
234        self
235    }
236
237    pub fn kill_all(&self) {
238        let entries = self.entries.lock().unwrap();
239        for (_, entry) in entries.iter() {
240            let _ = entry.control_tx.try_send(BgControl::Kill);
241        }
242    }
243
244    pub fn spawn(
245        self: &Arc<Self>,
246        cmd: String,
247        timeout_ms: Option<u64>,
248        max_output_bytes: u64,
249        ctx: &ToolCtx,
250    ) -> Result<Value, RuntimeError> {
251        let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".to_string());
252        let local_id = uuid::Uuid::now_v7().as_u64_pair().0;
253        let handle = BgHandle {
254            session_id: session_id.clone(),
255            local_id,
256        };
257        let handle_str = handle.to_string();
258
259        let dir = ctx.session_dir.clone().ok_or_else(|| {
260            RuntimeError::ToolFailed("bash.spawn: session_dir not available".into())
261        })?;
262        std::fs::create_dir_all(&dir).map_err(|e| {
263            RuntimeError::ToolFailed(format!("bash.spawn: create session_dir: {e}"))
264        })?;
265        let log_path = dir.join(format!("bg_{}.log", handle_str));
266
267        let timeout = match timeout_ms {
268            Some(0) => None,
269            Some(ms) => Some(Duration::from_millis(ms.min(MAX_SPAWN_TIMEOUT_MS))),
270            None => Some(Duration::from_millis(DEFAULT_SPAWN_TIMEOUT_MS)),
271        };
272
273        let (control_tx, control_rx) = mpsc::channel::<BgControl>(8);
274        let status = Arc::new(Mutex::new(BgStatus::Running {
275            pid: 0,
276            started_at: now_ms(),
277        }));
278        let output = Arc::new(Mutex::new(BgOutput::default()));
279        let cancel = ctx.cancel.clone();
280        let task_cancel = cancel.child_token();
281
282        let task_id = self.task_registry.as_ref().map(|tr| {
283            tr.register(
284                TaskKind::Bash,
285                cmd.clone(),
286                handle_str.clone(),
287                session_id.clone(),
288                task_cancel.clone(),
289            )
290        });
291
292        let entry = Arc::new(BgEntry {
293            session_id: session_id.clone(),
294            control_tx,
295            status: status.clone(),
296            output: output.clone(),
297            log_path: log_path.clone(),
298            task_id: task_id.clone(),
299        });
300        {
301            let mut entries = self.entries.lock().unwrap();
302            entries.insert(handle_str.clone(), entry.clone());
303        }
304
305        let registry = Arc::clone(self);
306        let handle_str_for_task = handle_str.clone();
307        let status_for_task = status.clone();
308        let log_path_for_return = log_path.clone();
309        let stream_tx = ctx.stream_tx.clone();
310        let handle_for_task = handle_str.clone();
311        let task_registry = self.task_registry.clone();
312        let task_id_for_spawn = task_id.clone();
313        let flow_run_id = ctx.flow_run_id.as_ref().map(|r| r.0.to_string());
314        tokio::spawn(async move {
315            run_bg_process(
316                handle_str_for_task,
317                cmd,
318                timeout,
319                max_output_bytes,
320                log_path,
321                status_for_task,
322                output,
323                control_rx,
324                task_cancel,
325                registry,
326                stream_tx,
327                handle_for_task,
328                task_registry,
329                task_id_for_spawn,
330                flow_run_id,
331            )
332            .await;
333        });
334
335        let pid = {
336            let s = status.lock().unwrap();
337            if let BgStatus::Running { pid, .. } = &*s {
338                *pid
339            } else {
340                0
341            }
342        };
343
344        Ok(Value::Struct(vec![
345            ("handle".into(), Value::Str(handle_str)),
346            ("status".into(), Value::Str("running".into())),
347            ("pid".into(), Value::Int(pid as i64)),
348            (
349                "log_path".into(),
350                Value::Str(log_path_for_return.to_string_lossy().into_owned()),
351            ),
352        ]))
353    }
354
355    pub fn lookup(&self, handle_str: &str, session_id: &str) -> Result<Arc<BgEntry>, RuntimeError> {
356        let handle = BgHandle::parse(handle_str).ok_or_else(|| {
357            RuntimeError::ToolFailed(format!("bash: invalid handle `{handle_str}`"))
358        })?;
359        if handle.session_id != session_id {
360            return Err(RuntimeError::ToolFailed(format!(
361                "bash: handle `{handle_str}` does not belong to session `{session_id}`"
362            )));
363        }
364        let entries = self.entries.lock().unwrap();
365        entries.get(handle_str).cloned().ok_or_else(|| {
366            RuntimeError::ToolFailed(format!("bash: handle `{handle_str}` not found"))
367        })
368    }
369
370    pub fn status(&self, handle_str: &str, session_id: &str) -> Result<Value, RuntimeError> {
371        let entry = self.lookup(handle_str, session_id)?;
372        let st = entry.status.lock().unwrap().clone();
373        let out = entry.output.lock().unwrap();
374        let mut fields = vec![
375            ("handle".into(), Value::Str(handle_str.into())),
376            ("status".into(), Value::Str(st.kind().into())),
377            ("started_at".into(), Value::Int(st.started_at())),
378            (
379                "log_path".into(),
380                Value::Str(entry.log_path.to_string_lossy().into_owned()),
381            ),
382        ];
383        if let Some(ec) = st.exit_code() {
384            fields.push(("exit_code".into(), Value::Int(ec as i64)));
385        }
386        if let Some(ended) = st.ended_at() {
387            fields.push(("ended_at".into(), Value::Int(ended)));
388        }
389        fields.push(("bytes_total".into(), Value::Int(out.total_bytes as i64)));
390        fields.push(("output_truncated".into(), Value::Bool(out.truncated)));
391        Ok(Value::Struct(fields))
392    }
393
394    pub fn output(
395        &self,
396        handle_str: &str,
397        session_id: &str,
398        session_dir: Option<&std::path::Path>,
399        cursor: usize,
400        limit: usize,
401    ) -> Result<Value, RuntimeError> {
402        if let Ok(entry) = self.lookup(handle_str, session_id) {
403            let st = entry.status.lock().unwrap().clone();
404            let out = entry.output.lock().unwrap();
405            let (chunk, next, eof) = out.read_from(cursor, limit);
406            return Ok(Value::Struct(vec![
407                ("handle".into(), Value::Str(handle_str.into())),
408                ("status".into(), Value::Str(st.kind().into())),
409                (
410                    "chunk".into(),
411                    Value::Str(String::from_utf8_lossy(&chunk).into_owned()),
412                ),
413                ("cursor".into(), Value::Int(cursor as i64)),
414                ("next_cursor".into(), Value::Int(next as i64)),
415                ("eof".into(), Value::Bool(eof)),
416                ("truncated".into(), Value::Bool(out.truncated)),
417                ("live".into(), Value::Bool(true)),
418            ]));
419        }
420
421        let Some(dir) = session_dir else {
422            return Err(RuntimeError::ToolFailed(format!(
423                "bash: handle `{handle_str}` not found"
424            )));
425        };
426        let log_path = dir.join(format!("bg_{handle_str}.log"));
427        let data = std::fs::read(&log_path).map_err(|_| {
428            RuntimeError::ToolFailed(format!("bash: handle `{handle_str}` not found"))
429        })?;
430        if cursor >= data.len() {
431            return Ok(Value::Struct(vec![
432                ("handle".into(), Value::Str(handle_str.into())),
433                ("status".into(), Value::Str("exited".into())),
434                ("chunk".into(), Value::Str(String::new())),
435                ("cursor".into(), Value::Int(cursor as i64)),
436                ("next_cursor".into(), Value::Int(data.len() as i64)),
437                ("eof".into(), Value::Bool(true)),
438                ("truncated".into(), Value::Bool(false)),
439                ("live".into(), Value::Bool(false)),
440            ]));
441        }
442        let take = (data.len() - cursor).min(limit);
443        let chunk = data[cursor..cursor + take].to_vec();
444        let next = cursor + take;
445        let eof = next >= data.len();
446        Ok(Value::Struct(vec![
447            ("handle".into(), Value::Str(handle_str.into())),
448            ("status".into(), Value::Str("exited".into())),
449            (
450                "chunk".into(),
451                Value::Str(String::from_utf8_lossy(&chunk).into_owned()),
452            ),
453            ("cursor".into(), Value::Int(cursor as i64)),
454            ("next_cursor".into(), Value::Int(next as i64)),
455            ("eof".into(), Value::Bool(eof)),
456            ("truncated".into(), Value::Bool(false)),
457            ("live".into(), Value::Bool(false)),
458        ]))
459    }
460
461    pub fn kill(&self, handle_str: &str, session_id: &str) -> Result<Value, RuntimeError> {
462        let entry = self.lookup(handle_str, session_id)?;
463        let _ = entry.control_tx.try_send(BgControl::Kill);
464        let st = entry.status.lock().unwrap().clone();
465        Ok(Value::Struct(vec![
466            ("handle".into(), Value::Str(handle_str.into())),
467            ("status".into(), Value::Str(st.kind().into())),
468        ]))
469    }
470
471    fn remove(&self, handle_str: &str) {
472        self.entries.lock().unwrap().remove(handle_str);
473    }
474
475    #[doc(hidden)]
476    pub fn clear_for_test(&self) {
477        self.entries.lock().unwrap().clear();
478    }
479
480    pub fn list(
481        &self,
482        session_id: &str,
483        session_dir: Option<&std::path::Path>,
484        all: bool,
485    ) -> Value {
486        let entries = self.entries.lock().unwrap();
487        let mut live_handles: std::collections::HashSet<String> = std::collections::HashSet::new();
488        let mut items: Vec<Value> = entries
489            .iter()
490            .filter(|(_, e)| e.session_id == session_id)
491            .map(|(handle, entry)| {
492                live_handles.insert(handle.clone());
493                let st = entry.status.lock().unwrap().clone();
494                let out = entry.output.lock().unwrap();
495                let mut fields = vec![
496                    ("handle".into(), Value::Str(handle.clone())),
497                    ("status".into(), Value::Str(st.kind().into())),
498                    ("started_at".into(), Value::Int(st.started_at())),
499                    ("live".into(), Value::Bool(true)),
500                ];
501                if let Some(ec) = st.exit_code() {
502                    fields.push(("exit_code".into(), Value::Int(ec as i64)));
503                }
504                fields.push(("bytes_total".into(), Value::Int(out.total_bytes as i64)));
505                Value::Struct(fields)
506            })
507            .collect();
508
509        if all {
510            if let Some(dir) = session_dir {
511                if let Ok(rd) = std::fs::read_dir(dir) {
512                    for entry in rd.flatten() {
513                        let name = entry.file_name();
514                        let name = name.to_string_lossy();
515                        let Some(rest) = name
516                            .strip_prefix("bg_")
517                            .and_then(|s| s.strip_suffix(".log"))
518                        else {
519                            continue;
520                        };
521                        let handle: String = rest.to_string();
522                        if live_handles.contains(&handle) {
523                            continue;
524                        }
525                        let Ok(meta) = entry.metadata() else {
526                            continue;
527                        };
528                        let modified = meta
529                            .modified()
530                            .ok()
531                            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
532                            .map(|d| d.as_millis() as i64)
533                            .unwrap_or(0);
534                        items.push(Value::Struct(vec![
535                            ("handle".into(), Value::Str(handle)),
536                            ("status".into(), Value::Str("exited".into())),
537                            ("started_at".into(), Value::Int(modified)),
538                            ("live".into(), Value::Bool(false)),
539                            ("bytes_total".into(), Value::Int(meta.len() as i64)),
540                        ]));
541                    }
542                }
543            }
544        }
545
546        Value::List(items)
547    }
548}
549
550impl Drop for BgRegistry {
551    fn drop(&mut self) {
552        self.kill_all();
553    }
554}
555
556#[allow(clippy::too_many_arguments)]
557async fn run_bg_process(
558    handle_str: String,
559    cmd: String,
560    timeout: Option<Duration>,
561    max_output_bytes: u64,
562    log_path: std::path::PathBuf,
563    status: Arc<Mutex<BgStatus>>,
564    output: Arc<Mutex<BgOutput>>,
565    mut control_rx: mpsc::Receiver<BgControl>,
566    cancel: CancellationToken,
567    registry: Arc<BgRegistry>,
568    stream_tx: Option<tokio::sync::broadcast::Sender<crate::stream::StreamFrame>>,
569    handle_for_stream: String,
570    task_registry: Option<TaskRegistry>,
571    task_id: Option<crate::task_registry::TaskId>,
572    flow_run_id: Option<String>,
573) {
574    let started_at = now_ms();
575    let mut command = tokio::process::Command::new("sh");
576    command
577        .arg("-c")
578        .arg(&cmd)
579        .stdin(Stdio::null())
580        .stdout(Stdio::piped())
581        .stderr(Stdio::piped());
582    let mut child: AsyncGroupChild = match command.group().kill_on_drop(true).spawn() {
583        Ok(c) => c,
584        Err(e) => {
585            *status.lock().unwrap() = BgStatus::Failed {
586                error: format!("spawn: {e}"),
587                started_at,
588                ended_at: now_ms(),
589            };
590            registry.remove(&handle_str);
591            return;
592        }
593    };
594    let pid = child.id();
595    *status.lock().unwrap() = BgStatus::Running {
596        pid: pid.unwrap_or(0),
597        started_at,
598    };
599
600    let stdout = child.inner().stdout.take();
601    let stderr = child.inner().stderr.take();
602
603    let stdout_reader = stdout.map(|s| {
604        let ctx = ReadStreamCtx {
605            output: output.clone(),
606            log_path: log_path.clone(),
607            kind: StreamKind::Stdout,
608            max_output_bytes,
609            stream_tx: stream_tx.clone(),
610            handle: handle_for_stream.clone(),
611            flow_run_id: flow_run_id.clone(),
612        };
613        tokio::spawn(read_stream(BufReader::new(s), ctx))
614    });
615    let stderr_reader = stderr.map(|s| {
616        let ctx = ReadStreamCtx {
617            output: output.clone(),
618            log_path: log_path.clone(),
619            kind: StreamKind::Stderr,
620            max_output_bytes,
621            stream_tx: stream_tx.clone(),
622            handle: handle_for_stream.clone(),
623            flow_run_id: flow_run_id.clone(),
624        };
625        tokio::spawn(read_stream(BufReader::new(s), ctx))
626    });
627
628    let exit_reason = tokio::select! {
629        biased;
630        _ = cancel.cancelled() => ExitReason::Cancelled,
631        ctrl = control_rx.recv() => {
632            match ctrl {
633                Some(BgControl::Kill) => ExitReason::Kill,
634                None => ExitReason::Natural,
635            }
636        }
637        _ = async {
638            if let Some(t) = timeout {
639                tokio::time::sleep(t).await;
640            } else {
641                std::future::pending::<()>().await;
642            }
643        } => ExitReason::Timeout,
644        s = child.wait() => ExitReason::Exited(s),
645    };
646
647    let ended_at = now_ms();
648    let final_status = match &exit_reason {
649        ExitReason::Exited(Ok(s)) => BgStatus::Exited {
650            exit_code: s.code().unwrap_or(-1),
651            started_at,
652            ended_at,
653        },
654        ExitReason::Timeout => {
655            let _ = child.start_kill();
656            let _ = tokio::time::timeout(Duration::from_millis(500), child.wait()).await;
657            BgStatus::TimedOut {
658                started_at,
659                ended_at,
660            }
661        }
662        ExitReason::Kill => {
663            let _ = child.start_kill();
664            let _ = tokio::time::timeout(Duration::from_millis(500), child.wait()).await;
665            BgStatus::Killed {
666                started_at,
667                ended_at,
668            }
669        }
670        ExitReason::Cancelled => {
671            let _ = child.start_kill();
672            let _ = tokio::time::timeout(Duration::from_millis(500), child.wait()).await;
673            BgStatus::Killed {
674                started_at,
675                ended_at,
676            }
677        }
678        ExitReason::Exited(Err(_)) => {
679            let _ = child.start_kill();
680            BgStatus::Failed {
681                error: "wait failed".into(),
682                started_at,
683                ended_at,
684            }
685        }
686        ExitReason::Natural => {
687            let s = child.wait().await;
688            BgStatus::Exited {
689                exit_code: s.ok().and_then(|s| s.code()).unwrap_or(-1),
690                started_at,
691                ended_at: now_ms(),
692            }
693        }
694    };
695
696    if let Some(r) = stdout_reader {
697        let _ = tokio::time::timeout(IO_DRAIN_TIMEOUT, r).await;
698    }
699    if let Some(r) = stderr_reader {
700        let _ = tokio::time::timeout(IO_DRAIN_TIMEOUT, r).await;
701    }
702
703    let exit_code = match &final_status {
704        BgStatus::Exited { exit_code, .. } => Some(*exit_code),
705        _ => None,
706    };
707    *status.lock().unwrap() = final_status.clone();
708
709    if let Some(tx) = &stream_tx {
710        let _ = tx.send(crate::stream::StreamFrame::BashExited {
711            handle: handle_for_stream,
712            exit_code,
713            run_id: flow_run_id,
714        });
715    }
716
717    if let (Some(tr), Some(tid)) = (task_registry, task_id) {
718        let ts = match &final_status {
719            BgStatus::Exited { exit_code, .. } if *exit_code == 0 => TaskStatus::Ok,
720            BgStatus::Killed { .. } | BgStatus::TimedOut { .. } => TaskStatus::Killed,
721            _ => TaskStatus::Err,
722        };
723        tr.finish(&tid, ts);
724    }
725}
726
727struct ReadStreamCtx {
728    output: Arc<Mutex<BgOutput>>,
729    log_path: std::path::PathBuf,
730    kind: StreamKind,
731    max_output_bytes: u64,
732    stream_tx: Option<tokio::sync::broadcast::Sender<crate::stream::StreamFrame>>,
733    handle: String,
734    flow_run_id: Option<String>,
735}
736
737async fn read_stream<R: tokio::io::AsyncBufRead + Unpin>(mut reader: R, ctx: ReadStreamCtx) {
738    let prefix: &[u8] = match ctx.kind {
739        StreamKind::Stdout => b"[out] ",
740        StreamKind::Stderr => b"[err] ",
741    };
742    let kind_str = match ctx.kind {
743        StreamKind::Stdout => "stdout",
744        StreamKind::Stderr => "stderr",
745    };
746    let mut buf = String::new();
747    let mut log_file = tokio::fs::OpenOptions::new()
748        .append(true)
749        .create(true)
750        .open(&ctx.log_path)
751        .await
752        .ok();
753    loop {
754        buf.clear();
755        match reader.read_line(&mut buf).await {
756            Ok(0) => break,
757            Ok(_) => {
758                let data = buf.as_bytes();
759                {
760                    let mut out = ctx.output.lock().unwrap();
761                    out.push(ctx.kind, data, ctx.max_output_bytes);
762                }
763                if let Some(file) = log_file.as_mut() {
764                    let _ = file.write_all(prefix).await;
765                    let _ = file.write_all(data).await;
766                    if !data.ends_with(b"\n") {
767                        let _ = file.write_all(b"\n").await;
768                    }
769                }
770                if let Some(tx) = &ctx.stream_tx {
771                    let _ = tx.send(crate::stream::StreamFrame::BashChunk {
772                        handle: ctx.handle.clone(),
773                        kind: kind_str.to_string(),
774                        line: buf.clone(),
775                        run_id: ctx.flow_run_id.clone(),
776                    });
777                }
778            }
779            Err(_) => break,
780        }
781    }
782}
783
784enum ExitReason {
785    Exited(std::io::Result<std::process::ExitStatus>),
786    Timeout,
787    Kill,
788    Cancelled,
789    Natural,
790}
791
792fn now_ms() -> i64 {
793    chrono::Utc::now().timestamp_millis()
794}
795
796pub struct BashSpawn;
797
798impl Tool for BashSpawn {
799    fn name(&self) -> &str {
800        "bash.spawn"
801    }
802
803    fn tier(&self) -> Tier {
804        Tier::Four
805    }
806
807    fn description(&self) -> Option<&str> {
808        Some(
809            "Run a shell command via `sh -c`.\n\n\
810block=false (default): command runs in background, returns immediately with a\n\
811handle. The command keeps running — use bash.output to read its output later,\n\
812bash.status to check if it finished, bash.kill to stop it. Use this for:\n\
813- long-running commands (servers, watchers)\n\
814- commands where you need to check output incrementally\n\
815- when you want to do other things while the command runs\n\n\
816block=true: waits for the command to finish, then returns stdout/stderr/exit_code.\n\
817Use block_timeout_ms to set a max wait (default 30s). Use this for:\n\
818- short commands where you need the result immediately (ls, git status, echo)\n\
819- commands that finish quickly\n\n\
820Do NOT use `sleep` in your command to wait — use block=true with block_timeout_ms\n\
821instead, or use the sleep tool to pause the workflow.",
822        )
823    }
824
825    fn input_schema(&self) -> serde_json::Value {
826        serde_json::json!({
827            "type": "object",
828            "properties": {
829                "cmd": {"type": "string", "description": "Shell command line."},
830                "block": {"type": "boolean", "default": false, "description": "If true, wait for process to exit before returning."},
831                "block_timeout_ms": {"type": "integer", "description": "Only with block=true. Max wait. 0 = no timeout. Default 30000."},
832                "timeout_ms": {"type": "integer", "description": "Process kill timeout in ms. Default 1800000 (30min). 0 = no timeout."},
833                "max_output_bytes": {"type": "integer", "description": "Max combined output bytes. Default 10485760 (10MB)."}
834            },
835            "required": ["cmd"]
836        })
837    }
838
839    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
840        Box::pin(async move {
841            let cmd = extract_string(&args, "cmd", 0)?;
842            let block = args
843                .named("block")
844                .and_then(|v| {
845                    if let Value::Bool(b) = v {
846                        Some(*b)
847                    } else {
848                        None
849                    }
850                })
851                .unwrap_or(false);
852            let block_timeout_ms = extract_optional_int(&args, "block_timeout_ms")
853                .map(|v| v as u64)
854                .unwrap_or(30_000);
855            let timeout_ms = extract_optional_int(&args, "timeout_ms").map(|v| v as u64);
856            let max_output = extract_optional_int(&args, "max_output_bytes")
857                .map(|v| v as u64)
858                .unwrap_or(DEFAULT_MAX_OUTPUT_BYTES);
859            let registry = ctx.bg_registry.clone().ok_or_else(|| {
860                RuntimeError::ToolFailed("bash.spawn: registry not available".into())
861            })?;
862            let handle_str = registry.spawn(cmd, timeout_ms, max_output, ctx)?;
863
864            if !block {
865                return Ok(handle_str);
866            }
867
868            let handle_s = handle_str
869                .field("handle")
870                .and_then(|v| {
871                    if let Value::Str(s) = v {
872                        Some(s.clone())
873                    } else {
874                        None
875                    }
876                })
877                .ok_or_else(|| {
878                    RuntimeError::ToolFailed("bash.spawn: missing handle field".into())
879                })?;
880            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
881
882            let deadline = if block_timeout_ms == 0 {
883                None
884            } else {
885                Some(tokio::time::Instant::now() + Duration::from_millis(block_timeout_ms))
886            };
887            loop {
888                let entry = registry.lookup(&handle_s, &session_id)?;
889                let finished = {
890                    let st = entry.status.lock().unwrap();
891                    st.is_finished()
892                };
893                if finished {
894                    break;
895                }
896                if let Some(d) = deadline {
897                    if tokio::time::Instant::now() >= d {
898                        break;
899                    }
900                }
901                tokio::time::sleep(Duration::from_millis(50)).await;
902            }
903
904            let entry = registry.lookup(&handle_s, &session_id)?;
905            let st = entry.status.lock().unwrap().clone();
906            let out = entry.output.lock().unwrap();
907            let combined = String::from_utf8_lossy(&out.combined).into_owned();
908            let log_path = entry.log_path.to_string_lossy().into_owned();
909            Ok(Value::Struct(vec![
910                ("handle".into(), Value::Str(handle_s)),
911                ("status".into(), Value::Str(st.kind().into())),
912                (
913                    "exit_code".into(),
914                    st.exit_code()
915                        .map(|c| Value::Int(c as i64))
916                        .unwrap_or(Value::Unit),
917                ),
918                ("output".into(), Value::Str(combined)),
919                ("bytes_total".into(), Value::Int(out.total_bytes as i64)),
920                ("log_path".into(), Value::Str(log_path)),
921            ]))
922        })
923    }
924}
925
926pub struct BashStatus;
927
928impl Tool for BashStatus {
929    fn name(&self) -> &str {
930        "bash.status"
931    }
932
933    fn tier(&self) -> Tier {
934        Tier::Four
935    }
936
937    fn description(&self) -> Option<&str> {
938        Some("Check the status of a background bash process.")
939    }
940
941    fn input_schema(&self) -> serde_json::Value {
942        serde_json::json!({
943            "type": "object",
944            "properties": {"handle": {"type": "string"}},
945            "required": ["handle"]
946        })
947    }
948
949    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
950        Box::pin(async move {
951            let handle = extract_string(&args, "handle", 0)?;
952            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".to_string());
953            let registry = ctx.bg_registry.clone().ok_or_else(|| {
954                RuntimeError::ToolFailed("bash.status: registry not available".into())
955            })?;
956            registry.status(&handle, &session_id)
957        })
958    }
959}
960
961pub struct BashOutput;
962
963impl Tool for BashOutput {
964    fn name(&self) -> &str {
965        "bash.output"
966    }
967
968    fn tier(&self) -> Tier {
969        Tier::Four
970    }
971
972    fn description(&self) -> Option<&str> {
973        Some("Read output from a background bash process by byte cursor.")
974    }
975
976    fn input_schema(&self) -> serde_json::Value {
977        serde_json::json!({
978            "type": "object",
979            "properties": {
980                "handle": {"type": "string"},
981                "cursor": {"type": "integer", "description": "Byte offset to start reading. Default 0."},
982                "limit_bytes": {"type": "integer", "description": "Max bytes to return. Default 32000."}
983            },
984            "required": ["handle"]
985        })
986    }
987
988    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
989        Box::pin(async move {
990            let handle = extract_string(&args, "handle", 0)?;
991            let cursor = extract_optional_int(&args, "cursor").unwrap_or(0).max(0) as usize;
992            let limit = extract_optional_int(&args, "limit_bytes")
993                .unwrap_or(DEFAULT_OUTPUT_LIMIT as i64)
994                .max(1) as usize;
995            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".to_string());
996            let registry = ctx.bg_registry.clone().ok_or_else(|| {
997                RuntimeError::ToolFailed("bash.output: registry not available".into())
998            })?;
999            registry.output(
1000                &handle,
1001                &session_id,
1002                ctx.session_dir.as_deref(),
1003                cursor,
1004                limit,
1005            )
1006        })
1007    }
1008}
1009
1010pub struct BashKill;
1011
1012impl Tool for BashKill {
1013    fn name(&self) -> &str {
1014        "bash.kill"
1015    }
1016
1017    fn tier(&self) -> Tier {
1018        Tier::Four
1019    }
1020
1021    fn description(&self) -> Option<&str> {
1022        Some(
1023            "Kill a background bash process. signal=term (default) sends SIGTERM, signal=kill sends SIGKILL.",
1024        )
1025    }
1026
1027    fn input_schema(&self) -> serde_json::Value {
1028        serde_json::json!({
1029            "type": "object",
1030            "properties": {
1031                "handle": {"type": "string"},
1032                "signal": {"type": "string", "enum": ["term", "kill"], "description": "Default term."}
1033            },
1034            "required": ["handle"]
1035        })
1036    }
1037
1038    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1039        Box::pin(async move {
1040            let handle = extract_string(&args, "handle", 0)?;
1041            let _ = extract_string(&args, "signal", 1);
1042            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".to_string());
1043            let registry = ctx.bg_registry.clone().ok_or_else(|| {
1044                RuntimeError::ToolFailed("bash.kill: registry not available".into())
1045            })?;
1046            registry.kill(&handle, &session_id)
1047        })
1048    }
1049}
1050
1051pub struct BashList;
1052
1053impl Tool for BashList {
1054    fn name(&self) -> &str {
1055        "bash.list"
1056    }
1057
1058    fn tier(&self) -> Tier {
1059        Tier::Four
1060    }
1061
1062    fn description(&self) -> Option<&str> {
1063        Some(
1064            "List background bash processes for the current session. Default: only live processes. Pass all=true to include historical processes whose log files persist in session_dir (status=exited, live=false).",
1065        )
1066    }
1067
1068    fn input_schema(&self) -> serde_json::Value {
1069        serde_json::json!({
1070            "type": "object",
1071            "properties": {
1072                "all": {"type": "boolean", "description": "Include historical processes (default false)."}
1073            }
1074        })
1075    }
1076
1077    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1078        Box::pin(async move {
1079            let all = extract_optional_bool(&args, "all").unwrap_or(false);
1080            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".to_string());
1081            let registry = ctx.bg_registry.clone().ok_or_else(|| {
1082                RuntimeError::ToolFailed("bash.list: registry not available".into())
1083            })?;
1084            Ok(registry.list(&session_id, ctx.session_dir.as_deref(), all))
1085        })
1086    }
1087}
1088
1089fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
1090    let value = match args.named(name) {
1091        Some(v) => v,
1092        None => args.positional(pos)?,
1093    };
1094    match value {
1095        Value::Str(s) => Ok(s.clone()),
1096        other => Err(RuntimeError::TypeMismatch {
1097            expected: "string".into(),
1098            actual: other.kind_name().into(),
1099        }),
1100    }
1101}
1102
1103fn extract_optional_int(args: &ToolArgs, name: &str) -> Option<i64> {
1104    match args.named(name)? {
1105        Value::Int(n) => Some(*n),
1106        _ => None,
1107    }
1108}
1109
1110fn extract_optional_bool(args: &ToolArgs, name: &str) -> Option<bool> {
1111    match args.named(name)? {
1112        Value::Bool(b) => Some(*b),
1113        _ => None,
1114    }
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119    use super::*;
1120    use crate::tool::{ToolArgs, ToolCtx};
1121    use crate::value::Value;
1122    use std::sync::Arc;
1123    use tempfile::TempDir;
1124
1125    fn ctx_with_registry(registry: Arc<BgRegistry>, dir: &std::path::Path) -> ToolCtx {
1126        let mut ctx = ToolCtx::new();
1127        ctx.bg_registry = Some(registry);
1128        ctx.session_dir = Some(dir.to_path_buf());
1129        ctx.session_id = Some("test-session".to_string());
1130        ctx
1131    }
1132
1133    #[test]
1134    fn handle_parse_roundtrip() {
1135        let h = BgHandle {
1136            session_id: "abc".into(),
1137            local_id: 42,
1138        };
1139        let s = h.to_string();
1140        assert_eq!(s, "bg_abc_42");
1141        let back = BgHandle::parse(&s).unwrap();
1142        assert_eq!(back, h);
1143    }
1144
1145    #[test]
1146    fn handle_parse_rejects_bad_format() {
1147        assert!(BgHandle::parse("not_bg").is_none());
1148        assert!(BgHandle::parse("bg_nosuffix").is_none());
1149        assert!(BgHandle::parse("bg_x_notnum").is_none());
1150    }
1151
1152    #[tokio::test]
1153    async fn spawn_returns_immediately_with_running_status() {
1154        let registry = Arc::new(BgRegistry::new());
1155        let dir = TempDir::new().unwrap();
1156        let ctx = ctx_with_registry(registry.clone(), dir.path());
1157        let args = ToolArgs {
1158            positional: vec![Value::Str("echo hello".into())],
1159            named: vec![],
1160        };
1161        let v = BashSpawn.call(args, &ctx).await.unwrap();
1162        let Value::Struct(fields) = v else {
1163            panic!("expected struct")
1164        };
1165        let handle = fields
1166            .iter()
1167            .find(|(k, _)| k == "handle")
1168            .and_then(|(_, v)| {
1169                if let Value::Str(s) = v {
1170                    Some(s.clone())
1171                } else {
1172                    None
1173                }
1174            })
1175            .unwrap();
1176        assert!(handle.starts_with("bg_"));
1177        let status_val = fields.iter().find(|(k, _)| k == "status").unwrap();
1178        assert!(matches!(&status_val.1, Value::Str(s) if s == "running"));
1179    }
1180
1181    #[tokio::test]
1182    async fn spawn_then_status_reaches_exited() {
1183        let registry = Arc::new(BgRegistry::new());
1184        let dir = TempDir::new().unwrap();
1185        let ctx = ctx_with_registry(registry.clone(), dir.path());
1186        let spawn_args = ToolArgs {
1187            positional: vec![Value::Str("echo hello".into())],
1188            named: vec![],
1189        };
1190        let v = BashSpawn.call(spawn_args, &ctx).await.unwrap();
1191        let Value::Struct(fields) = v else { panic!() };
1192        let handle = fields
1193            .iter()
1194            .find(|(k, _)| k == "handle")
1195            .and_then(|(_, v)| {
1196                if let Value::Str(s) = v {
1197                    Some(s.clone())
1198                } else {
1199                    None
1200                }
1201            })
1202            .unwrap();
1203
1204        for _ in 0..50 {
1205            tokio::time::sleep(Duration::from_millis(50)).await;
1206            let status_args = ToolArgs {
1207                positional: vec![Value::Str(handle.clone())],
1208                named: vec![],
1209            };
1210            let s = BashStatus.call(status_args, &ctx).await.unwrap();
1211            if let Value::Struct(sf) = s {
1212                let kind = sf.iter().find(|(k, _)| k == "status").unwrap();
1213                if matches!(&kind.1, Value::Str(s) if s == "exited") {
1214                    let ec = sf.iter().find(|(k, _)| k == "exit_code").unwrap();
1215                    assert!(matches!(ec.1, Value::Int(0)));
1216                    return;
1217                }
1218            }
1219        }
1220        panic!("process did not exit in time");
1221    }
1222
1223    #[tokio::test]
1224    async fn spawn_output_captures_stdout() {
1225        let registry = Arc::new(BgRegistry::new());
1226        let dir = TempDir::new().unwrap();
1227        let ctx = ctx_with_registry(registry.clone(), dir.path());
1228        let spawn_args = ToolArgs {
1229            positional: vec![Value::Str("echo line1; echo line2".into())],
1230            named: vec![],
1231        };
1232        let v = BashSpawn.call(spawn_args, &ctx).await.unwrap();
1233        let Value::Struct(fields) = v else { panic!() };
1234        let handle = fields
1235            .iter()
1236            .find(|(k, _)| k == "handle")
1237            .and_then(|(_, v)| {
1238                if let Value::Str(s) = v {
1239                    Some(s.clone())
1240                } else {
1241                    None
1242                }
1243            })
1244            .unwrap();
1245
1246        tokio::time::sleep(Duration::from_millis(300)).await;
1247
1248        let out_args = ToolArgs {
1249            positional: vec![Value::Str(handle.clone())],
1250            named: vec![],
1251        };
1252        let o = BashOutput.call(out_args, &ctx).await.unwrap();
1253        let Value::Struct(of) = o else { panic!() };
1254        let chunk = of.iter().find(|(k, _)| k == "chunk").unwrap();
1255        if let Value::Str(s) = &chunk.1 {
1256            assert!(s.contains("line1"), "chunk should contain line1: {s}");
1257            assert!(s.contains("line2"), "chunk should contain line2: {s}");
1258        } else {
1259            panic!("chunk not str");
1260        }
1261    }
1262
1263    #[tokio::test]
1264    async fn kill_terminates_long_running_process() {
1265        let registry = Arc::new(BgRegistry::new());
1266        let dir = TempDir::new().unwrap();
1267        let ctx = ctx_with_registry(registry.clone(), dir.path());
1268        let spawn_args = ToolArgs {
1269            positional: vec![Value::Str("sleep 100".into())],
1270            named: vec![],
1271        };
1272        let v = BashSpawn.call(spawn_args, &ctx).await.unwrap();
1273        let Value::Struct(fields) = v else { panic!() };
1274        let handle = fields
1275            .iter()
1276            .find(|(k, _)| k == "handle")
1277            .and_then(|(_, v)| {
1278                if let Value::Str(s) = v {
1279                    Some(s.clone())
1280                } else {
1281                    None
1282                }
1283            })
1284            .unwrap();
1285
1286        let kill_args = ToolArgs {
1287            positional: vec![Value::Str(handle.clone())],
1288            named: vec![],
1289        };
1290        BashKill.call(kill_args, &ctx).await.unwrap();
1291
1292        for _ in 0..50 {
1293            tokio::time::sleep(Duration::from_millis(50)).await;
1294            let status_args = ToolArgs {
1295                positional: vec![Value::Str(handle.clone())],
1296                named: vec![],
1297            };
1298            let s = BashStatus.call(status_args, &ctx).await.unwrap();
1299            if let Value::Struct(sf) = s {
1300                let kind = sf.iter().find(|(k, _)| k == "status").unwrap();
1301                if matches!(&kind.1, Value::Str(s) if s == "killed") {
1302                    return;
1303                }
1304            }
1305        }
1306        panic!("process not killed in time");
1307    }
1308
1309    #[tokio::test]
1310    async fn cross_session_access_rejected() {
1311        let registry = Arc::new(BgRegistry::new());
1312        let dir = TempDir::new().unwrap();
1313        let ctx_a = ctx_with_registry(registry.clone(), dir.path());
1314
1315        let spawn_args = ToolArgs {
1316            positional: vec![Value::Str("sleep 10".into())],
1317            named: vec![],
1318        };
1319        let v = BashSpawn.call(spawn_args, &ctx_a).await.unwrap();
1320        let Value::Struct(fields) = v else { panic!() };
1321        let handle = fields
1322            .iter()
1323            .find(|(k, _)| k == "handle")
1324            .and_then(|(_, v)| {
1325                if let Value::Str(s) = v {
1326                    Some(s.clone())
1327                } else {
1328                    None
1329                }
1330            })
1331            .unwrap();
1332
1333        let mut ctx_b = ToolCtx::new();
1334        ctx_b.bg_registry = Some(registry.clone());
1335        ctx_b.session_dir = Some(dir.path().to_path_buf());
1336        ctx_b.session_id = Some("other-session".to_string());
1337        let status_args = ToolArgs {
1338            positional: vec![Value::Str(handle)],
1339            named: vec![],
1340        };
1341        let err = BashStatus.call(status_args, &ctx_b).await.err().unwrap();
1342        assert!(format!("{err}").contains("does not belong to session"));
1343    }
1344}