Skip to main content

aft/bash_background/
mod.rs

1//! Background bash task management. Phase 0 stub; Phase 1 Track D fills in.
2
3pub mod buffer;
4pub mod persistence;
5pub mod process;
6pub mod registry;
7pub mod watchdog;
8
9use crate::context::AppContext;
10use crate::protocol::Response;
11use serde::{Deserialize, Serialize};
12use serde_json::json;
13use std::collections::HashMap;
14use std::path::PathBuf;
15use std::time::Duration;
16
17pub use registry::{BgCompletion, BgTaskRegistry};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct BgTaskInfo {
21    pub task_id: String,
22    pub status: BgTaskStatus,
23    pub command: String,
24    pub started_at: u64,
25    pub duration_ms: Option<u64>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29#[serde(rename_all = "snake_case")]
30pub enum BgTaskStatus {
31    Starting,
32    Running,
33    Killing,
34    Completed,
35    Failed,
36    Killed,
37    TimedOut,
38}
39
40impl BgTaskStatus {
41    pub fn is_terminal(&self) -> bool {
42        matches!(
43            self,
44            BgTaskStatus::Completed
45                | BgTaskStatus::Failed
46                | BgTaskStatus::Killed
47                | BgTaskStatus::TimedOut
48        )
49    }
50}
51
52/// Spawn a bash command in the background. Returns a task_id immediately.
53#[allow(clippy::too_many_arguments)]
54pub fn spawn(
55    request_id: &str,
56    session_id: &str,
57    command: &str,
58    workdir: Option<PathBuf>,
59    env: Option<HashMap<String, String>>,
60    timeout_ms: Option<u64>,
61    ctx: &AppContext,
62    require_background_flag: bool,
63    notify_on_completion: bool,
64    compressed: bool,
65) -> Response {
66    if require_background_flag && !ctx.config().experimental_bash_background {
67        return Response::error(
68            request_id,
69            "feature_disabled",
70            "background bash is disabled; set `experimental.bash.background: true` in aft.jsonc",
71        );
72    }
73
74    let workdir = workdir.unwrap_or_else(|| {
75        ctx.config().project_root.clone().unwrap_or_else(|| {
76            std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
77        })
78    });
79    let storage_dir = storage_dir(ctx.config().storage_dir.as_deref());
80    let max_running = ctx.config().max_background_bash_tasks;
81    let timeout = timeout_ms.map(Duration::from_millis);
82    let project_root = ctx
83        .config()
84        .project_root
85        .clone()
86        .or_else(|| std::env::current_dir().ok())
87        .and_then(|path| std::fs::canonicalize(&path).ok().or(Some(path)));
88
89    match ctx.bash_background().spawn(
90        command,
91        session_id.to_string(),
92        workdir,
93        env.unwrap_or_default(),
94        timeout,
95        storage_dir,
96        max_running,
97        notify_on_completion,
98        compressed,
99        project_root,
100    ) {
101        Ok(task_id) => Response::success(
102            request_id,
103            json!({
104                "task_id": task_id,
105                "status": BgTaskStatus::Running,
106            }),
107        ),
108        Err(message) if message.contains("limit exceeded") => {
109            Response::error(request_id, "background_task_limit_exceeded", message)
110        }
111        Err(message) => Response::error(request_id, "execution_failed", message),
112    }
113}
114
115pub fn storage_dir(configured: Option<&std::path::Path>) -> PathBuf {
116    if let Some(dir) = configured {
117        return dir.to_path_buf();
118    }
119    if let Some(dir) = std::env::var_os("AFT_CACHE_DIR") {
120        return PathBuf::from(dir).join("aft");
121    }
122    // Fallback to the user's home directory. On Unix this is `$HOME`; on
123    // Windows `HOME` is typically unset, so fall back to `USERPROFILE`
124    // (which is always set in interactive sessions and in the env that
125    // OpenCode/Pi pass through to plugin processes). If both are missing
126    // (rare — embedded contexts, broken shells), fall back to a temp
127    // directory rather than `"."` — a relative path makes bg-bash wrapper
128    // commands like `move /Y .\.cache\aft\... ...` fail with "system
129    // cannot find the path specified" once the working directory shifts.
130    let home = std::env::var_os("HOME")
131        .or_else(|| std::env::var_os("USERPROFILE"))
132        .map(PathBuf::from)
133        .unwrap_or_else(std::env::temp_dir);
134    home.join(".cache").join("aft")
135}