aft/bash_background/
mod.rs1pub mod buffer;
4pub mod persistence;
5pub mod process;
6pub mod pty_process;
7pub mod pty_runtime;
8pub mod registry;
9pub mod watchdog;
10pub mod watches;
11
12use crate::context::AppContext;
13use crate::protocol::Response;
14use persistence::BgMode;
15use serde::{Deserialize, Serialize};
16use serde_json::json;
17use std::collections::HashMap;
18use std::path::PathBuf;
19use std::time::Duration;
20
21pub use registry::{BgCompletion, BgTaskRegistry};
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct BgTaskInfo {
25 pub task_id: String,
26 pub status: BgTaskStatus,
27 pub command: String,
28 pub mode: BgMode,
29 pub started_at: u64,
30 pub duration_ms: Option<u64>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34#[serde(rename_all = "snake_case")]
35pub enum BgTaskStatus {
36 Starting,
37 Running,
38 Killing,
39 Completed,
40 Failed,
41 Killed,
42 TimedOut,
43}
44
45impl BgTaskStatus {
46 pub fn is_terminal(&self) -> bool {
47 matches!(
48 self,
49 BgTaskStatus::Completed
50 | BgTaskStatus::Failed
51 | BgTaskStatus::Killed
52 | BgTaskStatus::TimedOut
53 )
54 }
55}
56
57#[allow(clippy::too_many_arguments)]
59pub fn spawn(
60 request_id: &str,
61 session_id: &str,
62 command: &str,
63 workdir: Option<PathBuf>,
64 env: Option<HashMap<String, String>>,
65 timeout_ms: Option<u64>,
66 ctx: &AppContext,
67 require_background_flag: bool,
68 notify_on_completion: bool,
69 compressed: bool,
70 pty: bool,
71 pty_rows: u16,
72 pty_cols: u16,
73) -> Response {
74 if require_background_flag && !ctx.config().experimental_bash_background {
75 return Response::error(
76 request_id,
77 "feature_disabled",
78 "background bash is disabled; set `experimental.bash.background: true` in aft.jsonc",
79 );
80 }
81
82 let workdir = workdir.unwrap_or_else(|| {
83 ctx.config().project_root.clone().unwrap_or_else(|| {
84 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
85 })
86 });
87 let storage_dir = storage_dir(ctx.config().storage_dir.as_deref());
88 let max_running = ctx.config().max_background_bash_tasks;
89 let timeout = timeout_ms.map(Duration::from_millis);
90 let project_root = ctx
91 .config()
92 .project_root
93 .clone()
94 .or_else(|| std::env::current_dir().ok())
95 .and_then(|path| std::fs::canonicalize(&path).ok().or(Some(path)));
96
97 let env = env.unwrap_or_default();
98 let spawn_result = if pty {
99 ctx.bash_background().spawn_pty(
100 command,
101 session_id.to_string(),
102 workdir,
103 env,
104 timeout,
105 storage_dir,
106 max_running,
107 notify_on_completion,
108 compressed,
109 project_root,
110 pty_rows,
111 pty_cols,
112 )
113 } else {
114 ctx.bash_background().spawn(
115 command,
116 session_id.to_string(),
117 workdir,
118 env,
119 timeout,
120 storage_dir,
121 max_running,
122 notify_on_completion,
123 compressed,
124 project_root,
125 )
126 };
127
128 match spawn_result {
129 Ok(task_id) => Response::success(
130 request_id,
131 json!({
132 "task_id": task_id,
133 "status": BgTaskStatus::Running,
134 "mode": if pty { "pty" } else { "pipes" },
135 }),
136 ),
137 Err(message) if message.contains("limit exceeded") => {
138 Response::error(request_id, "background_task_limit_exceeded", message)
139 }
140 Err(message) => Response::error(request_id, "execution_failed", message),
141 }
142}
143
144pub fn storage_dir(configured: Option<&std::path::Path>) -> PathBuf {
145 if let Some(dir) = configured {
146 return dir.to_path_buf();
147 }
148 if let Some(dir) = std::env::var_os("AFT_CACHE_DIR") {
149 return PathBuf::from(dir).join("aft");
150 }
151 let home = std::env::var_os("HOME")
160 .or_else(|| std::env::var_os("USERPROFILE"))
161 .map(PathBuf::from)
162 .unwrap_or_else(std::env::temp_dir);
163 home.join(".cache").join("aft")
164}