1use 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#[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 pub interactive: bool,
26 pub method: String,
27 pub message: String,
28}
29
30#[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
38pub trait InteractiveRunner {
41 fn run(&self, plan: &AttachPlan) -> InteractiveRunOutput;
42}
43
44#[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
75pub 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
131pub 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 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
230pub 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
242pub 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
264pub 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
279pub 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;