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 = {
88 let config = ctx.config();
89 let root = storage_dir(config.storage_dir.as_deref());
90 config
91 .harness
92 .map(|harness| root.join(harness.as_str()))
93 .unwrap_or(root)
94 };
95 let max_running = ctx.config().max_background_bash_tasks;
96 let timeout = timeout_ms.map(Duration::from_millis);
97 let project_root = ctx
98 .config()
99 .project_root
100 .clone()
101 .or_else(|| std::env::current_dir().ok())
102 .and_then(|path| std::fs::canonicalize(&path).ok().or(Some(path)));
103
104 let env = env.unwrap_or_default();
105 let spawn_result = if pty {
106 ctx.bash_background().spawn_pty(
107 command,
108 session_id.to_string(),
109 workdir,
110 env,
111 timeout,
112 storage_dir,
113 max_running,
114 notify_on_completion,
115 compressed,
116 project_root,
117 pty_rows,
118 pty_cols,
119 )
120 } else {
121 ctx.bash_background().spawn(
122 command,
123 session_id.to_string(),
124 workdir,
125 env,
126 timeout,
127 storage_dir,
128 max_running,
129 notify_on_completion,
130 compressed,
131 project_root,
132 )
133 };
134
135 match spawn_result {
136 Ok(task_id) => Response::success(
137 request_id,
138 json!({
139 "task_id": task_id,
140 "status": BgTaskStatus::Running,
141 "mode": if pty { "pty" } else { "pipes" },
142 }),
143 ),
144 Err(message) if message.contains("limit exceeded") => {
145 Response::error(request_id, "background_task_limit_exceeded", message)
146 }
147 Err(message) => Response::error(request_id, "execution_failed", message),
148 }
149}
150
151pub fn storage_dir(configured: Option<&std::path::Path>) -> PathBuf {
152 if let Some(dir) = configured {
153 return dir.to_path_buf();
154 }
155 if let Some(dir) = std::env::var_os("AFT_CACHE_DIR") {
156 return PathBuf::from(dir).join("aft");
157 }
158 let home = std::env::var_os("HOME")
167 .or_else(|| std::env::var_os("USERPROFILE"))
168 .map(PathBuf::from)
169 .unwrap_or_else(std::env::temp_dir);
170 home.join(".cache").join("aft")
171}
172
173pub fn repair_legacy_root_tasks(storage_root: &std::path::Path, harness: crate::harness::Harness) {
174 let root_tasks = storage_root.join("bash-tasks");
175 if !dir_has_entries(&root_tasks) {
176 return;
177 }
178
179 let harness_tasks = storage_root.join(harness.as_str()).join("bash-tasks");
180 if dir_has_entries(&harness_tasks) {
181 return;
182 }
183 if let Some(parent) = harness_tasks.parent() {
184 if let Err(error) = std::fs::create_dir_all(parent) {
185 crate::slog_warn!(
186 "failed to create harness bash task dir {}: {}",
187 parent.display(),
188 error
189 );
190 return;
191 }
192 }
193 if harness_tasks.exists() {
194 let _ = std::fs::remove_dir(&harness_tasks);
195 }
196
197 match std::fs::rename(&root_tasks, &harness_tasks) {
198 Ok(()) => crate::slog_info!(
199 "moved legacy root bash tasks into harness namespace: {}",
200 harness_tasks.display()
201 ),
202 Err(error) => {
203 crate::slog_warn!(
204 "failed to move legacy root bash tasks into {}: {}; trying child merge",
205 harness_tasks.display(),
206 error
207 );
208 if std::fs::create_dir_all(&harness_tasks).is_err() {
209 return;
210 }
211 if let Ok(entries) = std::fs::read_dir(&root_tasks) {
212 for entry in entries.flatten() {
213 let source = entry.path();
214 let target = harness_tasks.join(entry.file_name());
215 if !target.exists() {
216 let _ = std::fs::rename(source, target);
217 }
218 }
219 }
220 let _ = std::fs::remove_dir(&root_tasks);
221 }
222 }
223}
224
225fn dir_has_entries(path: &std::path::Path) -> bool {
226 std::fs::read_dir(path)
227 .map(|mut entries| entries.next().is_some())
228 .unwrap_or(false)
229}