Skip to main content

start_command/
execution_attach.rs

1//! Attach to a tracked detached execution (issue #162).
2//!
3//! `--stop`/`--terminate` can already address a stored session name;
4//! `--attach` completes the set by re-entering the session's terminal instead
5//! of only tailing its log. `--attach --read-only` follows the output without
6//! forwarding stdin, which is the safe default for supervisors that must not
7//! accidentally type into a long-running agent session.
8
9use std::process::{Command, Stdio};
10
11use crate::docker_cleanup::docker_command;
12use crate::execution_control::{CommandRunner, SystemCommandRunner};
13use crate::execution_store::{ExecutionRecord, ExecutionStore};
14use crate::output_blocks::escape_for_links_notation;
15use crate::session_probe::{probe_session, SessionProbe, SessionState};
16
17/// A resolved way back into a running session.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct AttachPlan {
20    pub backend: String,
21    pub session_name: String,
22    pub command: String,
23    pub args: Vec<String>,
24    /// Whether stdin is forwarded to the session.
25    pub interactive: bool,
26    pub method: String,
27    pub message: String,
28}
29
30/// Outcome of running an attach plan.
31#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct InteractiveRunOutput {
33    pub success: bool,
34    pub status: Option<i32>,
35    pub error: Option<String>,
36}
37
38/// Runs an attach plan with the parent terminal wired up. Injectable so tests
39/// never spawn a real `docker attach`.
40pub trait InteractiveRunner {
41    fn run(&self, plan: &AttachPlan) -> InteractiveRunOutput;
42}
43
44/// Runs the plan as a child process sharing this process' terminal.
45#[derive(Debug, Default)]
46pub struct SystemInteractiveRunner;
47
48impl InteractiveRunner for SystemInteractiveRunner {
49    fn run(&self, plan: &AttachPlan) -> InteractiveRunOutput {
50        let mut command = Command::new(&plan.command);
51        command
52            .args(&plan.args)
53            .stdout(Stdio::inherit())
54            .stderr(Stdio::inherit())
55            .stdin(if plan.interactive {
56                Stdio::inherit()
57            } else {
58                Stdio::null()
59            });
60        match command.status() {
61            Ok(status) => InteractiveRunOutput {
62                success: status.success(),
63                status: status.code(),
64                error: None,
65            },
66            Err(err) => InteractiveRunOutput {
67                success: false,
68                status: None,
69                error: Some(err.to_string()),
70            },
71        }
72    }
73}
74
75/// Result of `attach_execution`.
76pub struct ExecutionAttachResult {
77    pub success: bool,
78    pub output: Option<String>,
79    pub error: Option<String>,
80    pub exit_code: Option<i32>,
81}
82
83fn record_option<'a>(record: &'a ExecutionRecord, key: &str) -> Option<&'a str> {
84    record.options.get(key).and_then(|value| value.as_str())
85}
86
87fn log_follow_plan(
88    record: &ExecutionRecord,
89    backend: &str,
90    session_name: &str,
91) -> Result<AttachPlan, String> {
92    if record.log_path.is_empty() {
93        return Err(format!(
94            "Execution \"{}\" has no stored log to follow.",
95            record.uuid
96        ));
97    }
98    Ok(AttachPlan {
99        backend: backend.to_string(),
100        session_name: session_name.to_string(),
101        command: "tail".to_string(),
102        args: vec!["-f".to_string(), record.log_path.clone()],
103        interactive: false,
104        method: "LOG_FOLLOW".to_string(),
105        message: format!(
106            "Following stored log for {} session: {}",
107            backend, session_name
108        ),
109    })
110}
111
112fn not_running_error(record: &ExecutionRecord, probe: &SessionProbe, session_name: &str) -> String {
113    if probe.state == SessionState::Missing {
114        return format!(
115            "Session \"{}\" no longer exists, so there is nothing to attach to. \
116Use `$ --resume {}` to start it again, or `$ --status {}` to inspect the recorded result.",
117            session_name, record.uuid, record.uuid
118        );
119    }
120    let state = probe
121        .container_status
122        .clone()
123        .unwrap_or_else(|| probe.state.as_str().to_string());
124    format!(
125        "Session \"{}\" is not running (state: {}). \
126Use `$ --resume {}` to continue it in the same container, or `$ --status {}` to inspect the recorded result.",
127        session_name, state, record.uuid, record.uuid
128    )
129}
130
131/// Build the attach plan for an execution record.
132pub fn build_attach_plan(
133    record: &ExecutionRecord,
134    read_only: bool,
135    probe: &SessionProbe,
136) -> Result<AttachPlan, String> {
137    let session_name = record_option(record, "sessionName")
138        .ok_or_else(|| "Execution record does not contain an isolation session name.".to_string())?
139        .to_string();
140
141    if record_option(record, "isolationMode") != Some("detached") {
142        return Err("Only detached isolated executions can be attached to.".to_string());
143    }
144
145    let backend = record_option(record, "isolated")
146        .unwrap_or("unknown")
147        .to_string();
148
149    // ssh sessions cannot be probed or re-entered locally: the stored log is
150    // the only channel back into them.
151    if backend == "ssh" {
152        return log_follow_plan(record, &backend, &session_name);
153    }
154
155    if !probe.alive {
156        return Err(not_running_error(record, probe, &session_name));
157    }
158
159    let docker = docker_command().to_string_lossy().to_string();
160
161    match backend.as_str() {
162        "screen" if read_only => log_follow_plan(record, &backend, &session_name),
163        "screen" => Ok(AttachPlan {
164            backend,
165            command: "screen".to_string(),
166            args: vec!["-r".to_string(), session_name.clone()],
167            interactive: true,
168            method: "SCREEN_ATTACH".to_string(),
169            message: format!("Attaching to detached screen session: {}", session_name),
170            session_name,
171        }),
172        "tmux" => Ok(AttachPlan {
173            backend,
174            args: if read_only {
175                vec![
176                    "attach-session".to_string(),
177                    "-r".to_string(),
178                    "-t".to_string(),
179                    session_name.clone(),
180                ]
181            } else {
182                vec![
183                    "attach-session".to_string(),
184                    "-t".to_string(),
185                    session_name.clone(),
186                ]
187            },
188            command: "tmux".to_string(),
189            interactive: true,
190            method: if read_only {
191                "TMUX_ATTACH_READONLY".to_string()
192            } else {
193                "TMUX_ATTACH".to_string()
194            },
195            message: format!(
196                "Attaching to detached tmux session: {}{}",
197                session_name,
198                if read_only { " (read-only)" } else { "" }
199            ),
200            session_name,
201        }),
202        "docker" if read_only => Ok(AttachPlan {
203            backend,
204            command: docker,
205            args: vec!["logs".to_string(), "-f".to_string(), session_name.clone()],
206            interactive: false,
207            method: "DOCKER_LOG_FOLLOW".to_string(),
208            message: format!(
209                "Following logs of detached docker container: {}",
210                session_name
211            ),
212            session_name,
213        }),
214        "docker" => Ok(AttachPlan {
215            backend,
216            command: docker,
217            args: vec!["attach".to_string(), session_name.clone()],
218            interactive: true,
219            method: "DOCKER_ATTACH".to_string(),
220            message: format!("Attaching to detached docker container: {}", session_name),
221            session_name,
222        }),
223        other => Err(format!(
224            "Attaching to detached {} executions is not supported.",
225            other
226        )),
227    }
228}
229
230/// Fields reported after an attach attempt.
231pub struct AttachResultFields<'a> {
232    pub identifier: &'a str,
233    pub uuid: &'a str,
234    pub backend: &'a str,
235    pub session_name: &'a str,
236    pub method: &'a str,
237    pub read_only: bool,
238    pub command: &'a str,
239    pub message: &'a str,
240}
241
242/// Format an attach result as links notation.
243pub fn format_attach_result_as_links_notation(result: &AttachResultFields) -> String {
244    [
245        "executionAttach".to_string(),
246        format!(
247            "  identifier {}",
248            escape_for_links_notation(result.identifier)
249        ),
250        format!("  uuid {}", escape_for_links_notation(result.uuid)),
251        format!("  backend {}", escape_for_links_notation(result.backend)),
252        format!(
253            "  sessionName {}",
254            escape_for_links_notation(result.session_name)
255        ),
256        format!("  method {}", escape_for_links_notation(result.method)),
257        format!("  readOnly {}", result.read_only),
258        format!("  command {}", escape_for_links_notation(result.command)),
259        format!("  message {}", escape_for_links_notation(result.message)),
260    ]
261    .join("\n")
262}
263
264/// Attach to a tracked execution by UUID or session name.
265pub fn attach_execution(
266    store: Option<&ExecutionStore>,
267    identifier: &str,
268    read_only: bool,
269) -> ExecutionAttachResult {
270    attach_execution_with_runners(
271        store,
272        identifier,
273        read_only,
274        &SystemCommandRunner,
275        &SystemInteractiveRunner,
276    )
277}
278
279/// Attach with injectable runners, so tests never touch a real session.
280pub fn attach_execution_with_runners<R: CommandRunner, I: InteractiveRunner>(
281    store: Option<&ExecutionStore>,
282    identifier: &str,
283    read_only: bool,
284    runner: &R,
285    interactive_runner: &I,
286) -> ExecutionAttachResult {
287    let Some(store) = store else {
288        return ExecutionAttachResult {
289            success: false,
290            output: None,
291            error: Some("Execution tracking is disabled.".to_string()),
292            exit_code: None,
293        };
294    };
295
296    let Some(record) = store.get(identifier) else {
297        return ExecutionAttachResult {
298            success: false,
299            output: None,
300            error: Some(format!(
301                "No execution found with UUID or session name: {}",
302                identifier
303            )),
304            exit_code: None,
305        };
306    };
307
308    let probe = probe_session(&record, runner);
309    let plan = match build_attach_plan(&record, read_only, &probe) {
310        Ok(plan) => plan,
311        Err(error) => {
312            return ExecutionAttachResult {
313                success: false,
314                output: None,
315                error: Some(error),
316                exit_code: None,
317            }
318        }
319    };
320
321    let command_line = std::iter::once(plan.command.clone())
322        .chain(plan.args.iter().cloned())
323        .collect::<Vec<_>>()
324        .join(" ");
325    let run_result = interactive_runner.run(&plan);
326
327    if !run_result.success {
328        let detail = run_result.error.clone().unwrap_or_else(|| {
329            format!(
330                "exit code {}",
331                run_result
332                    .status
333                    .map(|code| code.to_string())
334                    .unwrap_or_else(|| "unknown".to_string())
335            )
336        });
337        return ExecutionAttachResult {
338            success: false,
339            output: None,
340            error: Some(format!(
341                "Failed to attach to {} session \"{}\": {}",
342                plan.backend, plan.session_name, detail
343            )),
344            exit_code: Some(run_result.status.unwrap_or(1)),
345        };
346    }
347
348    ExecutionAttachResult {
349        success: true,
350        output: Some(format_attach_result_as_links_notation(
351            &AttachResultFields {
352                identifier,
353                uuid: &record.uuid,
354                backend: &plan.backend,
355                session_name: &plan.session_name,
356                method: &plan.method,
357                read_only,
358                command: &command_line,
359                message: &plan.message,
360            },
361        )),
362        error: None,
363        exit_code: None,
364    }
365}
366
367#[cfg(test)]
368#[path = "execution_attach_cases.rs"]
369mod tests;