1use crate::docker_cleanup::docker_command;
8use crate::execution_store::{ExecutionRecord, ExecutionStore};
9use crate::output_blocks::{escape_for_links_notation, format_value_for_links_notation};
10use serde_json::{json, Map, Value};
11use std::collections::{HashSet, VecDeque};
12use std::process::Command;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ControlAction {
16 Stop,
17 Terminate,
18}
19
20impl ControlAction {
21 pub fn as_str(self) -> &'static str {
22 match self {
23 ControlAction::Stop => "stop",
24 ControlAction::Terminate => "terminate",
25 }
26 }
27}
28
29#[derive(Debug, Clone, Default, PartialEq, Eq)]
30pub struct CommandRunOutput {
31 pub success: bool,
32 pub stdout: String,
33 pub stderr: String,
34 pub status: Option<i32>,
35 pub error: Option<String>,
36}
37
38pub trait CommandRunner {
39 fn run(&self, command: &str, args: &[String]) -> CommandRunOutput;
40}
41
42#[derive(Debug, Default)]
43pub struct SystemCommandRunner;
44
45impl CommandRunner for SystemCommandRunner {
46 fn run(&self, command: &str, args: &[String]) -> CommandRunOutput {
47 match Command::new(command).args(args).output() {
48 Ok(output) => CommandRunOutput {
49 success: output.status.success(),
50 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
51 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
52 status: output.status.code(),
53 error: None,
54 },
55 Err(err) => CommandRunOutput {
56 success: false,
57 stdout: String::new(),
58 stderr: String::new(),
59 status: None,
60 error: Some(err.to_string()),
61 },
62 }
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct ControlCommand {
68 pub command: String,
69 pub args: Vec<String>,
70 pub method: String,
71 pub message: String,
72}
73
74pub struct ExecutionControlResult {
75 pub success: bool,
76 pub output: Option<String>,
77 pub error: Option<String>,
78}
79
80fn parse_pid(value: &str) -> Option<u32> {
81 value.trim().parse::<u32>().ok().filter(|pid| *pid > 0)
82}
83
84fn parse_pids(output: &str) -> Vec<u32> {
85 output.split_whitespace().filter_map(parse_pid).collect()
86}
87
88pub fn parse_screen_pid(screen_list_output: &str, session_name: &str) -> Option<u32> {
89 for line in screen_list_output.lines() {
90 let first_column = line.split_whitespace().next().unwrap_or("");
91 let Some((pid, name)) = first_column.split_once('.') else {
92 continue;
93 };
94 if name == session_name {
95 return parse_pid(pid);
96 }
97 }
98 None
99}
100
101pub fn collect_descendant_pids_with_runner<R: CommandRunner>(
102 root_pid: u32,
103 runner: &R,
104) -> Vec<u32> {
105 let mut descendants = Vec::new();
106 let mut seen = HashSet::from([root_pid]);
107 let mut queue = VecDeque::from([root_pid]);
108
109 while let Some(parent_pid) = queue.pop_front() {
110 let args = vec!["-P".to_string(), parent_pid.to_string()];
111 let result = runner.run("pgrep", &args);
112 if !result.success && result.stdout.is_empty() {
113 continue;
114 }
115
116 for child_pid in parse_pids(&result.stdout) {
117 if seen.insert(child_pid) {
118 descendants.push(child_pid);
119 queue.push_back(child_pid);
120 }
121 }
122 }
123
124 descendants
125}
126
127pub fn collect_descendant_pids(root_pid: u32) -> Vec<u32> {
128 collect_descendant_pids_with_runner(root_pid, &SystemCommandRunner)
129}
130
131fn insert_if_present(map: &mut Map<String, Value>, key: &str, value: Option<Value>) {
132 if let Some(value) = value {
133 if !matches!(&value, Value::Array(items) if items.is_empty()) {
134 map.insert(key.to_string(), value);
135 }
136 }
137}
138
139fn option_value_as_string(value: Option<&Value>) -> Option<String> {
140 match value {
141 Some(Value::String(s)) => Some(s.clone()),
142 Some(Value::Number(n)) => Some(n.to_string()),
143 _ => None,
144 }
145}
146
147pub fn collect_process_ids(record: &ExecutionRecord) -> Option<Value> {
148 collect_process_ids_with_runner(record, &SystemCommandRunner)
149}
150
151pub fn collect_process_ids_with_runner<R: CommandRunner>(
152 record: &ExecutionRecord,
153 runner: &R,
154) -> Option<Value> {
155 let mut process_ids = Map::new();
156 insert_if_present(
157 &mut process_ids,
158 "wrapperPid",
159 record.pid.map(|pid| json!(pid)),
160 );
161
162 let session_name = record
163 .options
164 .get("sessionName")
165 .and_then(|value| value.as_str());
166 let isolated = record
167 .options
168 .get("isolated")
169 .and_then(|value| value.as_str());
170
171 let (Some(session_name), Some(isolated)) = (session_name, isolated) else {
172 return (!process_ids.is_empty()).then_some(Value::Object(process_ids));
173 };
174
175 match isolated {
176 "screen" => {
177 let result = runner.run("screen", &["-ls".to_string()]);
178 let output = format!("{}{}", result.stdout, result.stderr);
179 if let Some(screen_pid) = parse_screen_pid(&output, session_name) {
180 insert_if_present(&mut process_ids, "screenPid", Some(json!(screen_pid)));
181 insert_if_present(
182 &mut process_ids,
183 "commandPids",
184 Some(json!(collect_descendant_pids_with_runner(
185 screen_pid, runner
186 ))),
187 );
188 }
189 }
190 "tmux" => {
191 let tmux_pid_args = vec![
192 "display-message".to_string(),
193 "-p".to_string(),
194 "-t".to_string(),
195 session_name.to_string(),
196 "#{pid}".to_string(),
197 ];
198 let tmux_pid_result = runner.run("tmux", &tmux_pid_args);
199 insert_if_present(
200 &mut process_ids,
201 "tmuxPid",
202 parse_pid(&tmux_pid_result.stdout).map(|pid| json!(pid)),
203 );
204
205 let pane_args = vec![
206 "list-panes".to_string(),
207 "-t".to_string(),
208 session_name.to_string(),
209 "-F".to_string(),
210 "#{pane_pid}".to_string(),
211 ];
212 let pane_result = runner.run("tmux", &pane_args);
213 let pane_pids = parse_pids(&pane_result.stdout);
214 insert_if_present(&mut process_ids, "panePids", Some(json!(pane_pids)));
215
216 let mut command_pids = Vec::new();
217 let mut seen = HashSet::new();
218 for pane_pid in parse_pids(&pane_result.stdout) {
219 for command_pid in collect_descendant_pids_with_runner(pane_pid, runner) {
220 if seen.insert(command_pid) {
221 command_pids.push(command_pid);
222 }
223 }
224 }
225 insert_if_present(&mut process_ids, "commandPids", Some(json!(command_pids)));
226 }
227 "docker" => {
228 insert_if_present(
229 &mut process_ids,
230 "containerId",
231 option_value_as_string(record.options.get("containerId")).map(Value::String),
232 );
233 let inspect_args = vec![
234 "inspect".to_string(),
235 "-f".to_string(),
236 "{{.Id}} {{.State.Pid}}".to_string(),
237 session_name.to_string(),
238 ];
239 let docker = docker_command().to_string_lossy().to_string();
240 let result = runner.run(&docker, &inspect_args);
241 if result.success && !result.stdout.trim().is_empty() {
242 let mut parts = result.stdout.split_whitespace();
243 if let Some(container_id) = parts.next() {
244 insert_if_present(
245 &mut process_ids,
246 "containerId",
247 Some(Value::String(container_id.to_string())),
248 );
249 }
250 if let Some(pid_value) = parts.next().and_then(parse_pid) {
251 insert_if_present(&mut process_ids, "containerPid", Some(json!(pid_value)));
252 }
253 }
254 }
255 "ssh" => {
256 insert_if_present(
257 &mut process_ids,
258 "remotePid",
259 record.options.get("remotePid").cloned(),
260 );
261 }
262 _ => {}
263 }
264
265 (!process_ids.is_empty()).then_some(Value::Object(process_ids))
266}
267
268pub fn get_control_command(
269 record: &ExecutionRecord,
270 action: ControlAction,
271) -> Result<ControlCommand, String> {
272 let session_name = record
273 .options
274 .get("sessionName")
275 .and_then(|value| value.as_str())
276 .ok_or_else(|| {
277 "Execution record does not contain an isolation session name.".to_string()
278 })?;
279
280 let isolation_mode = record
281 .options
282 .get("isolationMode")
283 .and_then(|value| value.as_str());
284 if isolation_mode != Some("detached") {
285 return Err("Only detached isolated executions can be stopped or terminated.".to_string());
286 }
287
288 let backend = record
289 .options
290 .get("isolated")
291 .and_then(|value| value.as_str())
292 .unwrap_or("unknown");
293
294 let command = match (action, backend) {
295 (ControlAction::Stop, "screen") => ControlCommand {
296 command: "screen".to_string(),
297 args: vec![
298 "-S".to_string(),
299 session_name.to_string(),
300 "-X".to_string(),
301 "stuff".to_string(),
302 "\u{3}".to_string(),
303 ],
304 method: "CTRL_C".to_string(),
305 message: format!("Sent CTRL+C to detached screen session: {}", session_name),
306 },
307 (ControlAction::Stop, "tmux") => ControlCommand {
308 command: "tmux".to_string(),
309 args: vec![
310 "send-keys".to_string(),
311 "-t".to_string(),
312 session_name.to_string(),
313 "C-c".to_string(),
314 ],
315 method: "CTRL_C".to_string(),
316 message: format!("Sent CTRL+C to detached tmux session: {}", session_name),
317 },
318 (ControlAction::Stop, "docker") => ControlCommand {
319 command: "docker".to_string(),
320 args: vec!["stop".to_string(), session_name.to_string()],
321 method: "DOCKER_STOP".to_string(),
322 message: format!(
323 "Requested graceful stop for detached docker container: {}",
324 session_name
325 ),
326 },
327 (ControlAction::Terminate, "screen") => ControlCommand {
328 command: "screen".to_string(),
329 args: vec![
330 "-S".to_string(),
331 session_name.to_string(),
332 "-X".to_string(),
333 "quit".to_string(),
334 ],
335 method: "SCREEN_QUIT".to_string(),
336 message: format!("Terminated detached screen session: {}", session_name),
337 },
338 (ControlAction::Terminate, "tmux") => ControlCommand {
339 command: "tmux".to_string(),
340 args: vec![
341 "kill-session".to_string(),
342 "-t".to_string(),
343 session_name.to_string(),
344 ],
345 method: "KILL_SESSION".to_string(),
346 message: format!("Terminated detached tmux session: {}", session_name),
347 },
348 (ControlAction::Terminate, "docker") => ControlCommand {
349 command: "docker".to_string(),
350 args: vec!["kill".to_string(), session_name.to_string()],
351 method: "SIGKILL".to_string(),
352 message: format!("Terminated detached docker container: {}", session_name),
353 },
354 (ControlAction::Stop, other) => {
355 return Err(format!(
356 "Stopping detached {} executions is not supported.",
357 other
358 ));
359 }
360 (ControlAction::Terminate, other) => {
361 return Err(format!(
362 "Terminating detached {} executions is not supported.",
363 other
364 ));
365 }
366 };
367
368 Ok(command)
369}
370
371fn append_links_array(lines: &mut Vec<String>, values: &[Value], indent: usize) {
372 let prefix = " ".repeat(indent);
373 if values.is_empty() {
374 lines.push(format!("{}()", prefix));
375 return;
376 }
377
378 lines.push(format!("{}(", prefix));
379 for value in values {
380 match value {
381 Value::Array(nested) => append_links_array(lines, nested, indent + 2),
382 Value::Object(map) => {
383 for (child_key, child_value) in map {
384 if !child_value.is_null() {
385 append_links_value(lines, child_key, child_value, indent + 2);
386 }
387 }
388 }
389 _ => lines.push(format!(
390 "{}{}",
391 " ".repeat(indent + 2),
392 format_value_for_links_notation(value)
393 )),
394 }
395 }
396 lines.push(format!("{})", prefix));
397}
398
399fn append_links_value(lines: &mut Vec<String>, key: &str, value: &Value, indent: usize) {
400 let prefix = " ".repeat(indent);
401 match value {
402 Value::Object(map) => {
403 lines.push(format!("{}{}", prefix, key));
404 for (child_key, child_value) in map {
405 if !child_value.is_null() {
406 append_links_value(lines, child_key, child_value, indent + 4);
407 }
408 }
409 }
410 Value::Array(values) => {
411 lines.push(format!("{}{}", prefix, key));
412 append_links_array(lines, values, indent + 2);
413 }
414 _ => {
415 lines.push(format!(
416 "{}{} {}",
417 prefix,
418 key,
419 format_value_for_links_notation(value)
420 ));
421 }
422 }
423}
424
425pub fn format_control_result_as_links_notation(
426 action: ControlAction,
427 identifier: &str,
428 record: &ExecutionRecord,
429 method: &str,
430 process_ids: Option<&Value>,
431 message: &str,
432) -> String {
433 let status = match action {
434 ControlAction::Stop => "signal-sent",
435 ControlAction::Terminate => "terminated",
436 };
437 format_control_result_with_status(
438 action,
439 identifier,
440 record,
441 status,
442 method,
443 process_ids,
444 message,
445 )
446}
447
448fn format_control_result_with_status(
449 action: ControlAction,
450 identifier: &str,
451 record: &ExecutionRecord,
452 status: &str,
453 method: &str,
454 process_ids: Option<&Value>,
455 message: &str,
456) -> String {
457 let backend = record
458 .options
459 .get("isolated")
460 .and_then(|value| value.as_str())
461 .unwrap_or("unknown");
462 let session_name = record
463 .options
464 .get("sessionName")
465 .and_then(|value| value.as_str())
466 .unwrap_or("");
467
468 let mut lines = vec![
469 "executionControl".to_string(),
470 format!(" action {}", escape_for_links_notation(action.as_str())),
471 format!(" identifier {}", escape_for_links_notation(identifier)),
472 format!(" uuid {}", escape_for_links_notation(&record.uuid)),
473 format!(" status {}", escape_for_links_notation(status)),
474 format!(" backend {}", escape_for_links_notation(backend)),
475 format!(" sessionName {}", escape_for_links_notation(session_name)),
476 format!(" method {}", escape_for_links_notation(method)),
477 ];
478
479 if let Some(process_ids) = process_ids {
480 append_links_value(&mut lines, "processIds", process_ids, 2);
481 }
482
483 lines.push(format!(" message {}", escape_for_links_notation(message)));
484 lines.join("\n")
485}
486
487pub fn control_execution(
488 store: Option<&ExecutionStore>,
489 identifier: &str,
490 action: ControlAction,
491) -> ExecutionControlResult {
492 control_execution_with_runner(store, identifier, action, &SystemCommandRunner)
493}
494
495fn has_kill_recovery(record: &ExecutionRecord) -> bool {
496 record
497 .options
498 .get("onKillResume")
499 .is_some_and(|value| value.as_u64().unwrap_or(0) > 0)
500}
501
502fn mark_stop_requested(store: &ExecutionStore, record: &mut ExecutionRecord) -> Option<Value> {
505 if !has_kill_recovery(record) {
506 return None;
507 }
508 let previous = record.options.insert(
509 "stopRequestedAt".to_string(),
510 json!(chrono::Utc::now().to_rfc3339()),
511 );
512 let _ = store.save(record);
514 previous
515}
516
517fn is_pending_recovery_stop(record: &ExecutionRecord, result: &CommandRunOutput) -> bool {
521 if record.options.get("isolated").and_then(Value::as_str) != Some("docker")
522 || !has_kill_recovery(record)
523 {
524 return false;
525 }
526 let detail = format!(
527 "{} {}",
528 result.stderr,
529 result.error.as_deref().unwrap_or_default()
530 );
531 detail.to_lowercase().contains("is not running")
532}
533
534fn restore_stop_requested(
535 store: &ExecutionStore,
536 record: &mut ExecutionRecord,
537 previous: Option<Value>,
538) {
539 if !has_kill_recovery(record) {
540 return;
541 }
542 match previous {
543 Some(value) => record.options.insert("stopRequestedAt".to_string(), value),
544 None => record.options.remove("stopRequestedAt"),
545 };
546 let _ = store.save(record);
548}
549
550pub fn control_execution_with_runner<R: CommandRunner>(
551 store: Option<&ExecutionStore>,
552 identifier: &str,
553 action: ControlAction,
554 runner: &R,
555) -> ExecutionControlResult {
556 let Some(store) = store else {
557 return ExecutionControlResult {
558 success: false,
559 output: None,
560 error: Some("Execution tracking is disabled.".to_string()),
561 };
562 };
563
564 let Some(mut record) = store.get(identifier) else {
565 return ExecutionControlResult {
566 success: false,
567 output: None,
568 error: Some(format!(
569 "No execution found with UUID or session name: {}",
570 identifier
571 )),
572 };
573 };
574
575 let control = match get_control_command(&record, action) {
576 Ok(command) => command,
577 Err(error) => {
578 return ExecutionControlResult {
579 success: false,
580 output: None,
581 error: Some(error),
582 }
583 }
584 };
585
586 let previous_stop_requested_at = mark_stop_requested(store, &mut record);
590 let result = runner.run(&control.command, &control.args);
591 if !result.success && is_pending_recovery_stop(&record, &result) {
592 let session_name = record
596 .options
597 .get("sessionName")
598 .and_then(Value::as_str)
599 .unwrap_or("")
600 .to_string();
601 let output = format_control_result_with_status(
602 action,
603 identifier,
604 &record,
605 "recovery-cancelled",
606 &control.method,
607 None,
608 &format!(
609 "Cancelled the pending recovery of detached docker container: {}",
610 session_name
611 ),
612 );
613 return ExecutionControlResult {
614 success: true,
615 output: Some(output),
616 error: None,
617 };
618 }
619 if !result.success {
620 restore_stop_requested(store, &mut record, previous_stop_requested_at);
621 let backend = record
622 .options
623 .get("isolated")
624 .and_then(|value| value.as_str())
625 .unwrap_or("unknown");
626 let session_name = record
627 .options
628 .get("sessionName")
629 .and_then(|value| value.as_str())
630 .unwrap_or("");
631 let detail = if !result.stderr.is_empty() {
632 result.stderr
633 } else if let Some(error) = result.error {
634 error
635 } else {
636 format!("exit code {}", result.status.unwrap_or(-1))
637 };
638
639 return ExecutionControlResult {
640 success: false,
641 output: None,
642 error: Some(format!(
643 "Failed to {} {} session \"{}\": {}",
644 action.as_str(),
645 backend,
646 session_name,
647 detail
648 )),
649 };
650 }
651
652 let process_ids = collect_process_ids_with_runner(&record, runner);
653 let output = format_control_result_as_links_notation(
654 action,
655 identifier,
656 &record,
657 &control.method,
658 process_ids.as_ref(),
659 &control.message,
660 );
661
662 ExecutionControlResult {
663 success: true,
664 output: Some(output),
665 error: None,
666 }
667}