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