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}