1pub 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, BgTaskHealthCounts, 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 cortexkit_data_root().join("cortexkit").join("aft")
171}
172
173fn cortexkit_data_root() -> PathBuf {
174 if let Some(dir) = std::env::var_os("XDG_DATA_HOME") {
175 if !dir.is_empty() {
176 return PathBuf::from(dir);
177 }
178 }
179 let home = std::env::var_os("HOME")
180 .or_else(|| std::env::var_os("USERPROFILE"))
181 .map(PathBuf::from)
182 .unwrap_or_else(std::env::temp_dir);
183 if cfg!(windows) {
184 return std::env::var_os("LOCALAPPDATA")
185 .or_else(|| std::env::var_os("APPDATA"))
186 .map(PathBuf::from)
187 .unwrap_or_else(|| home.join("AppData").join("Local"));
188 }
189 home.join(".local").join("share")
190}
191
192pub fn repair_legacy_root_tasks(storage_root: &std::path::Path, harness: crate::harness::Harness) {
193 let root_tasks = storage_root.join("bash-tasks");
194 if !dir_has_entries(&root_tasks) {
195 return;
196 }
197
198 let harness_tasks = storage_root
199 .join(harness.storage_segment())
200 .join("bash-tasks");
201 if dir_has_entries(&harness_tasks) {
202 return;
203 }
204 if let Some(parent) = harness_tasks.parent() {
205 if let Err(error) = std::fs::create_dir_all(parent) {
206 crate::slog_warn!(
207 "failed to create harness bash task dir {}: {}",
208 parent.display(),
209 error
210 );
211 return;
212 }
213 }
214 if harness_tasks.exists() {
215 let _ = std::fs::remove_dir(&harness_tasks);
216 }
217
218 match std::fs::rename(&root_tasks, &harness_tasks) {
219 Ok(()) => crate::slog_info!(
220 "moved legacy root bash tasks into harness namespace: {}",
221 harness_tasks.display()
222 ),
223 Err(error) => {
224 crate::slog_warn!(
225 "failed to move legacy root bash tasks into {}: {}; trying child merge",
226 harness_tasks.display(),
227 error
228 );
229 if std::fs::create_dir_all(&harness_tasks).is_err() {
230 return;
231 }
232 if let Ok(entries) = std::fs::read_dir(&root_tasks) {
233 for entry in entries.flatten() {
234 let source = entry.path();
235 let target = harness_tasks.join(entry.file_name());
236 if !target.exists() {
237 let _ = std::fs::rename(source, target);
238 }
239 }
240 }
241 let _ = std::fs::remove_dir(&root_tasks);
242 }
243 }
244}
245
246fn dir_has_entries(path: &std::path::Path) -> bool {
247 std::fs::read_dir(path)
248 .map(|mut entries| entries.next().is_some())
249 .unwrap_or(false)
250}
251
252#[cfg(test)]
253mod storage_root_tests {
254 use std::path::PathBuf;
255
256 #[test]
266 fn plugin_less_fallback_matches_plugin_injected_cortexkit_root() {
267 let _guard = crate::test_env::process_env_lock();
268 let data_home = std::env::var_os("XDG_DATA_HOME")
269 .filter(|value| !value.is_empty())
270 .map(PathBuf::from)
271 .unwrap_or_else(|| {
272 let home = std::env::var_os("HOME")
273 .or_else(|| std::env::var_os("USERPROFILE"))
274 .map(PathBuf::from)
275 .expect("test environment provides a home directory");
276 if cfg!(windows) {
277 std::env::var_os("LOCALAPPDATA")
278 .or_else(|| std::env::var_os("APPDATA"))
279 .map(PathBuf::from)
280 .unwrap_or_else(|| home.join("AppData").join("Local"))
281 } else {
282 home.join(".local").join("share")
283 }
284 });
285 let expected_plugin_injected_root = data_home.join("cortexkit").join("aft");
286
287 let cache_dir_override_absent = std::env::var_os("AFT_CACHE_DIR").is_none();
288 assert!(
289 cache_dir_override_absent,
290 "test requires AFT_CACHE_DIR unset to exercise the real fallback"
291 );
292
293 assert_eq!(
294 super::storage_dir(None),
295 expected_plugin_injected_root,
296 "bash_background::storage_dir fallback diverged from the plugin-injected root"
297 );
298
299 let temp_project = tempfile::tempdir().expect("temp project");
300 let resolved = crate::search_index::resolve_cache_dir(temp_project.path(), None);
301 assert!(
302 resolved.starts_with(&expected_plugin_injected_root),
303 "search_index::resolve_cache_dir fallback diverged: {}",
304 resolved.display()
305 );
306 }
307}