aft/bash_background/
mod.rs1pub mod buffer;
4pub mod output;
5pub mod persistence;
6pub mod process;
7pub mod pty_process;
8pub mod pty_runtime;
9pub mod registry;
10pub mod watchdog;
11pub mod watches;
12
13use crate::context::AppContext;
14use crate::protocol::Response;
15use persistence::BgMode;
16use serde::{Deserialize, Serialize};
17use serde_json::json;
18use std::collections::HashMap;
19use std::path::PathBuf;
20use std::time::Duration;
21
22pub use registry::{BgCompletion, BgTaskRegistry};
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct BgTaskInfo {
26 pub task_id: String,
27 pub status: BgTaskStatus,
28 pub command: String,
29 pub mode: BgMode,
30 pub started_at: u64,
31 pub duration_ms: Option<u64>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35#[serde(rename_all = "snake_case")]
36pub enum BgTaskStatus {
37 Starting,
38 Running,
39 Killing,
40 Completed,
41 Failed,
42 Killed,
43 TimedOut,
44}
45
46impl BgTaskStatus {
47 pub fn is_terminal(&self) -> bool {
48 matches!(
49 self,
50 BgTaskStatus::Completed
51 | BgTaskStatus::Failed
52 | BgTaskStatus::Killed
53 | BgTaskStatus::TimedOut
54 )
55 }
56}
57
58#[allow(clippy::too_many_arguments)]
60pub fn spawn(
61 request_id: &str,
62 session_id: &str,
63 command: &str,
64 workdir: Option<PathBuf>,
65 env: Option<HashMap<String, String>>,
66 timeout_ms: Option<u64>,
67 ctx: &AppContext,
68 require_background_flag: bool,
69 notify_on_completion: bool,
70 compressed: bool,
71 pty: bool,
72 pty_rows: u16,
73 pty_cols: u16,
74) -> Response {
75 if require_background_flag && !ctx.config().experimental_bash_background {
76 return Response::error(
77 request_id,
78 "feature_disabled",
79 "background bash is disabled; set `experimental.bash.background: true` in aft.jsonc",
80 );
81 }
82
83 let workdir = workdir.unwrap_or_else(|| {
84 ctx.config().project_root.clone().unwrap_or_else(|| {
85 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
86 })
87 });
88 let storage_dir = {
89 let config = ctx.config();
90 let root = storage_dir(config.storage_dir.as_deref());
91 config
92 .harness
93 .map(|harness| root.join(harness.as_str()))
94 .unwrap_or(root)
95 };
96 let max_running = ctx.config().max_background_bash_tasks;
97 let timeout = timeout_ms.map(Duration::from_millis);
98 let project_root = ctx
99 .config()
100 .project_root
101 .clone()
102 .or_else(|| std::env::current_dir().ok())
103 .and_then(|path| std::fs::canonicalize(&path).ok().or(Some(path)));
104
105 let env = env.unwrap_or_default();
106 let spawn_result = if pty {
107 ctx.bash_background().spawn_pty(
108 command,
109 session_id.to_string(),
110 workdir,
111 env,
112 timeout,
113 storage_dir,
114 max_running,
115 notify_on_completion,
116 compressed,
117 project_root,
118 pty_rows,
119 pty_cols,
120 )
121 } else {
122 ctx.bash_background().spawn(
123 command,
124 session_id.to_string(),
125 workdir,
126 env,
127 timeout,
128 storage_dir,
129 max_running,
130 notify_on_completion,
131 compressed,
132 project_root,
133 )
134 };
135
136 match spawn_result {
137 Ok(task_id) => Response::success(
138 request_id,
139 json!({
140 "task_id": task_id,
141 "status": BgTaskStatus::Running,
142 "mode": if pty { "pty" } else { "pipes" },
143 }),
144 ),
145 Err(message) if message.contains("limit exceeded") => {
146 Response::error(request_id, "background_task_limit_exceeded", message)
147 }
148 Err(message) => Response::error(request_id, "execution_failed", message),
149 }
150}
151
152pub fn storage_dir(configured: Option<&std::path::Path>) -> PathBuf {
153 if let Some(dir) = configured {
154 return dir.to_path_buf();
155 }
156 if let Some(dir) = std::env::var_os("AFT_CACHE_DIR") {
157 return PathBuf::from(dir).join("aft");
158 }
159 let home = std::env::var_os("HOME")
168 .or_else(|| std::env::var_os("USERPROFILE"))
169 .map(PathBuf::from)
170 .unwrap_or_else(std::env::temp_dir);
171 home.join(".cache").join("aft")
172}
173
174pub fn repair_legacy_root_tasks(storage_root: &std::path::Path, harness: crate::harness::Harness) {
175 let root_tasks = storage_root.join("bash-tasks");
176 if !dir_has_entries(&root_tasks) {
177 return;
178 }
179
180 let harness_tasks = storage_root.join(harness.as_str()).join("bash-tasks");
181 if dir_has_entries(&harness_tasks) {
182 return;
183 }
184 if let Some(parent) = harness_tasks.parent() {
185 if let Err(error) = std::fs::create_dir_all(parent) {
186 crate::slog_warn!(
187 "failed to create harness bash task dir {}: {}",
188 parent.display(),
189 error
190 );
191 return;
192 }
193 }
194 if harness_tasks.exists() {
195 let _ = std::fs::remove_dir(&harness_tasks);
196 }
197
198 match std::fs::rename(&root_tasks, &harness_tasks) {
199 Ok(()) => crate::slog_info!(
200 "moved legacy root bash tasks into harness namespace: {}",
201 harness_tasks.display()
202 ),
203 Err(error) => {
204 crate::slog_warn!(
205 "failed to move legacy root bash tasks into {}: {}; trying child merge",
206 harness_tasks.display(),
207 error
208 );
209 if std::fs::create_dir_all(&harness_tasks).is_err() {
210 return;
211 }
212 if let Ok(entries) = std::fs::read_dir(&root_tasks) {
213 for entry in entries.flatten() {
214 let source = entry.path();
215 let target = harness_tasks.join(entry.file_name());
216 if !target.exists() {
217 let _ = std::fs::rename(source, target);
218 }
219 }
220 }
221 let _ = std::fs::remove_dir(&root_tasks);
222 }
223 }
224}
225
226fn dir_has_entries(path: &std::path::Path) -> bool {
227 std::fs::read_dir(path)
228 .map(|mut entries| entries.next().is_some())
229 .unwrap_or(false)
230}