1use crate::docker_cleanup::docker_command;
9use crate::execution_control::collect_process_ids;
10use crate::execution_store::{ExecutionRecord, ExecutionStatus, ExecutionStore};
11use crate::output_blocks::{escape_for_links_notation, format_value_for_links_notation};
12use serde_json::Value;
13use std::fs;
14use std::process::Command;
15
16#[derive(Clone, Copy)]
18struct DockerState {
19 running: bool,
20 exit_code: Option<i32>,
21 oom_killed: Option<bool>,
22}
23
24fn inspect_docker_state(session_name: &str) -> Option<DockerState> {
36 let output = Command::new(docker_command())
37 .args([
38 "inspect",
39 "-f",
40 "{{.State.Running}} {{.State.ExitCode}} {{.State.OOMKilled}}",
41 session_name,
42 ])
43 .output()
44 .ok()?;
45 if !output.status.success() {
46 return None;
47 }
48 let stdout = String::from_utf8_lossy(&output.stdout);
49 let trimmed = stdout.trim();
50 if trimmed.is_empty() {
51 return None;
52 }
53 let mut parts = trimmed.split_whitespace();
54 let running = parts.next() == Some("true");
55 let exit_code = parts.next().and_then(|value| value.parse::<i32>().ok());
56 let oom_killed = parts.next().and_then(|value| match value {
57 "true" => Some(true),
58 "false" => Some(false),
59 _ => None,
60 });
61 Some(DockerState {
62 running,
63 exit_code,
64 oom_killed,
65 })
66}
67
68fn is_detached_docker_record(record: &ExecutionRecord) -> bool {
69 record.options.get("isolated").and_then(|v| v.as_str()) == Some("docker")
70 && record.options.get("isolationMode").and_then(|v| v.as_str()) == Some("detached")
71 && record
72 .options
73 .get("sessionName")
74 .and_then(|v| v.as_str())
75 .is_some()
76}
77
78fn read_docker_state(record: &ExecutionRecord) -> Option<DockerState> {
79 if record.options.get("isolated")?.as_str()? != "docker" {
80 return None;
81 }
82 let session_name = record.options.get("sessionName")?.as_str()?;
83 inspect_docker_state(session_name)
84}
85
86fn backend_exit_code(docker_state: Option<DockerState>) -> Option<i32> {
93 let state = docker_state?;
94 if state.running {
95 None
96 } else {
97 state.exit_code
98 }
99}
100
101fn resolve_oom_observation(
110 record: &ExecutionRecord,
111 docker_state: Option<DockerState>,
112) -> Option<bool> {
113 let inspected = docker_state.and_then(|state| state.oom_killed);
114 if record.oom_killed == Some(true) || inspected == Some(true) {
115 return Some(true);
116 }
117 if record.oom_killed == Some(false) || inspected == Some(false) {
118 return Some(false);
119 }
120 None
121}
122
123pub fn is_detached_session_alive(record: &ExecutionRecord) -> Option<bool> {
126 let session_name = record.options.get("sessionName")?.as_str()?;
127 let isolation_mode = record.options.get("isolationMode")?.as_str()?;
128 let isolated = record.options.get("isolated")?.as_str()?;
129
130 if isolation_mode != "detached" {
131 return None;
132 }
133
134 match isolated {
135 "screen" => {
136 let output = Command::new("screen").args(["-ls"]).output().ok()?;
137 let stdout = String::from_utf8_lossy(&output.stdout);
138 Some(stdout.contains(session_name))
139 }
140 "tmux" => {
141 let status = Command::new("tmux")
142 .args(["has-session", "-t", session_name])
143 .output()
144 .ok()?;
145 Some(status.status.success())
146 }
147 "docker" => {
148 inspect_docker_state(session_name).map(|state| state.running)
153 }
154 "ssh" => {
155 #[cfg(unix)]
157 {
158 if let Some(pid) = record.pid {
159 let result = unsafe { libc::kill(pid as i32, 0) };
160 Some(result == 0)
161 } else {
162 None
163 }
164 }
165 #[cfg(not(unix))]
166 {
167 let _ = record.pid;
168 None
169 }
170 }
171 _ => None,
172 }
173}
174
175fn read_exit_code_from_log(log_path: &str) -> Option<i32> {
176 let content = fs::read_to_string(log_path).ok()?;
177 content
178 .lines()
179 .rev()
180 .find_map(|line| line.trim().strip_prefix("Exit Code:"))
181 .and_then(|value| value.trim().parse::<i32>().ok())
182}
183
184pub fn enrich_detached_status(record: &ExecutionRecord) -> ExecutionRecord {
189 let footer_exit = read_exit_code_from_log(&record.log_path);
190 let is_detached_docker = is_detached_docker_record(record);
191 let docker_state = if is_detached_docker {
192 read_docker_state(record)
193 } else {
194 None
195 };
196
197 let oom_killed = resolve_oom_observation(record, docker_state);
199 let clone_record = || {
200 let mut enriched = record.clone();
201 if oom_killed.is_some() {
202 enriched.oom_killed = oom_killed;
203 }
204 enriched
205 };
206
207 let alive = if is_detached_docker {
208 docker_state.map(|state| state.running)
209 } else {
210 is_detached_session_alive(record)
211 };
212
213 let alive = match alive {
214 Some(value) => value,
215 None => {
216 let is_detached =
224 record.options.get("isolationMode").and_then(|v| v.as_str()) == Some("detached");
225 if is_detached && record.status == ExecutionStatus::Executing {
226 if footer_exit.is_some() {
227 let mut enriched = clone_record();
228 enriched.status = ExecutionStatus::Executed;
229 enriched.exit_code = footer_exit;
230 if enriched.end_time.is_none() {
231 enriched.end_time = Some(chrono::Utc::now().to_rfc3339());
232 }
233 return enriched;
234 }
235 if oom_killed == Some(true) {
236 let mut enriched = clone_record();
243 enriched.status = ExecutionStatus::Executed;
244 enriched.exit_code = Some(137);
245 if enriched.end_time.is_none() {
246 enriched.end_time = Some(chrono::Utc::now().to_rfc3339());
247 }
248 return enriched;
249 }
250 }
251 return clone_record();
252 }
253 };
254
255 let mut enriched = clone_record();
256
257 if alive && enriched.status == ExecutionStatus::Executed {
258 if enriched.exit_code.is_none() && footer_exit.is_none() {
265 enriched.status = ExecutionStatus::Executing;
267 enriched.exit_code = None;
268 enriched.end_time = None;
269 }
270 } else if !alive && enriched.status == ExecutionStatus::Executing {
272 enriched.status = ExecutionStatus::Executed;
278 if enriched.exit_code.is_none() {
279 enriched.exit_code = Some(
280 footer_exit
281 .or_else(|| backend_exit_code(docker_state))
282 .or(if oom_killed == Some(true) {
283 Some(137)
284 } else {
285 None
286 })
287 .unwrap_or(-1),
288 );
289 }
290 if enriched.end_time.is_none() {
291 enriched.end_time = Some(chrono::Utc::now().to_rfc3339());
292 }
293 }
294
295 enriched
296}
297
298pub fn attach_current_time(record: &ExecutionRecord) -> Option<String> {
303 if record.status == ExecutionStatus::Executing {
304 Some(chrono::Utc::now().to_rfc3339())
305 } else {
306 None
307 }
308}
309
310pub fn format_record_as_links_notation(record: &ExecutionRecord) -> String {
322 format_record_as_links_notation_with_current_time(record, None)
323}
324
325pub fn format_record_as_links_notation_with_current_time(
328 record: &ExecutionRecord,
329 current_time: Option<&str>,
330) -> String {
331 format_record_as_links_notation_with_enrichments(record, current_time, None)
332}
333
334fn append_links_array(lines: &mut Vec<String>, values: &[Value], indent: usize) {
335 let prefix = " ".repeat(indent);
336 if values.is_empty() {
337 lines.push(format!("{}()", prefix));
338 return;
339 }
340
341 lines.push(format!("{}(", prefix));
342 for value in values {
343 match value {
344 Value::Array(nested) => append_links_array(lines, nested, indent + 2),
345 Value::Object(map) => {
346 for (child_key, child_value) in map {
347 if !child_value.is_null() {
348 append_links_value(lines, child_key, child_value, indent + 2);
349 }
350 }
351 }
352 _ => lines.push(format!(
353 "{}{}",
354 " ".repeat(indent + 2),
355 format_value_for_links_notation(value)
356 )),
357 }
358 }
359 lines.push(format!("{})", prefix));
360}
361
362fn append_links_value(lines: &mut Vec<String>, key: &str, value: &Value, indent: usize) {
363 let prefix = " ".repeat(indent);
364 match value {
365 Value::Object(map) => {
366 if map.is_empty() {
367 return;
368 }
369 lines.push(format!("{}{}", prefix, key));
370 for (child_key, child_value) in map {
371 if !child_value.is_null() {
372 append_links_value(lines, child_key, child_value, indent + 4);
373 }
374 }
375 }
376 Value::Array(values) => {
377 lines.push(format!("{}{}", prefix, key));
378 append_links_array(lines, values, indent + 2);
379 }
380 _ => lines.push(format!(
381 "{}{} {}",
382 prefix,
383 key,
384 format_value_for_links_notation(value)
385 )),
386 }
387}
388
389fn format_record_as_links_notation_with_enrichments(
390 record: &ExecutionRecord,
391 current_time: Option<&str>,
392 process_ids: Option<&Value>,
393) -> String {
394 let json = record.to_json();
395 let mut lines = vec![record.uuid.clone()];
396
397 if let Value::Object(map) = json {
398 for (key, value) in map {
399 if !value.is_null() {
400 if key == "options" {
401 if let Value::Object(opts) = &value {
403 if !opts.is_empty() {
404 lines.push(" options".to_string());
405 for (opt_key, opt_value) in opts {
406 if !opt_value.is_null() {
407 let formatted = format_value_for_links_notation(opt_value);
408 lines.push(format!(" {} {}", opt_key, formatted));
409 }
410 }
411 }
412 }
413 } else {
414 let formatted_value = match &value {
415 Value::String(s) => escape_for_links_notation(s),
416 Value::Bool(b) => b.to_string(),
417 Value::Number(n) => n.to_string(),
418 Value::Null => "null".to_string(),
419 Value::Object(_) | Value::Array(_) => {
420 format_value_for_links_notation(&value)
422 }
423 };
424 lines.push(format!(" {} {}", key, formatted_value));
425 }
426 }
427
428 if key == "pid" {
431 if let Some(process_ids) = process_ids {
432 append_links_value(&mut lines, "processIds", process_ids, 2);
433 }
434 }
435
436 if key == "startTime" {
438 if let Some(ct) = current_time {
439 lines.push(format!(" currentTime {}", escape_for_links_notation(ct)));
440 }
441 }
442 }
443 }
444
445 lines.join("\n")
446}
447
448pub fn format_record_as_text(record: &ExecutionRecord) -> String {
450 format_record_as_text_with_current_time(record, None)
451}
452
453pub fn format_record_as_text_with_current_time(
456 record: &ExecutionRecord,
457 current_time: Option<&str>,
458) -> String {
459 format_record_as_text_with_enrichments(record, current_time, None)
460}
461
462fn append_text_process_ids(lines: &mut Vec<String>, process_ids: &Value) {
463 let Value::Object(map) = process_ids else {
464 return;
465 };
466 if map.is_empty() {
467 return;
468 }
469
470 lines.push("Process IDs:".to_string());
471 for (key, value) in map {
472 let value_str = match value {
473 Value::String(s) => s.clone(),
474 Value::Bool(b) => b.to_string(),
475 Value::Number(n) => n.to_string(),
476 Value::Null => "null".to_string(),
477 other => serde_json::to_string(other).unwrap_or_default(),
478 };
479 lines.push(format!(" {}: {}", key, value_str));
480 }
481}
482
483fn format_record_as_text_with_enrichments(
484 record: &ExecutionRecord,
485 current_time: Option<&str>,
486 process_ids: Option<&Value>,
487) -> String {
488 let exit_code_str = record
489 .exit_code
490 .map(|c| c.to_string())
491 .unwrap_or_else(|| "N/A".to_string());
492 let pid_str = record
493 .pid
494 .map(|p| p.to_string())
495 .unwrap_or_else(|| "N/A".to_string());
496 let end_time_str = record.end_time.as_deref().unwrap_or("N/A");
497
498 let mut lines = vec![
499 "Execution Status".to_string(),
500 "=".repeat(50),
501 format!("UUID: {}", record.uuid),
502 format!("Status: {}", record.status),
503 format!("Command: {}", record.command),
504 format!("Exit Code: {}", exit_code_str),
505 ];
506 if let Some(oom_killed) = record.oom_killed {
507 lines.push(format!("OOM Killed: {}", oom_killed));
508 }
509 lines.push(format!("PID: {}", pid_str));
510 if let Some(process_ids) = process_ids {
511 append_text_process_ids(&mut lines, process_ids);
512 }
513 lines.extend([
514 format!("Working Directory: {}", record.working_directory),
515 format!("Shell: {}", record.shell),
516 format!("Platform: {}", record.platform),
517 format!("Start Time: {}", record.start_time),
518 ]);
519 if let Some(ct) = current_time {
520 lines.push(format!("Current Time: {}", ct));
521 }
522 lines.push(format!("End Time: {}", end_time_str));
523 lines.push(format!("Log Path: {}", record.log_path));
524
525 if !record.options.is_empty() {
527 lines.push("Options:".to_string());
528 for (key, value) in &record.options {
529 let value_str = match value {
530 Value::String(s) => s.clone(),
531 Value::Bool(b) => b.to_string(),
532 Value::Number(n) => n.to_string(),
533 Value::Null => "null".to_string(),
534 other => serde_json::to_string(other).unwrap_or_default(),
535 };
536 lines.push(format!(" {}: {}", key, value_str));
537 }
538 }
539
540 lines.join("\n")
541}
542
543fn record_json_with_enrichments(
544 record: &ExecutionRecord,
545 current_time: Option<&str>,
546 process_ids: Option<&Value>,
547) -> Value {
548 let mut json = record.to_json();
549 if let Value::Object(map) = &mut json {
550 if let Some(process_ids) = process_ids {
551 map.insert("processIds".to_string(), process_ids.clone());
552 }
553 if let Some(ct) = current_time {
554 map.insert("currentTime".to_string(), Value::String(ct.to_string()));
555 }
556 }
557 json
558}
559
560pub fn format_record(record: &ExecutionRecord, format: &str) -> Result<String, String> {
562 format_record_with_current_time(record, format, None)
563}
564
565pub fn format_record_with_current_time(
569 record: &ExecutionRecord,
570 format: &str,
571 current_time: Option<&str>,
572) -> Result<String, String> {
573 format_record_with_enrichments(record, format, current_time, None)
574}
575
576fn format_record_with_enrichments(
577 record: &ExecutionRecord,
578 format: &str,
579 current_time: Option<&str>,
580 process_ids: Option<&Value>,
581) -> Result<String, String> {
582 match format {
583 "links-notation" => Ok(format_record_as_links_notation_with_enrichments(
584 record,
585 current_time,
586 process_ids,
587 )),
588 "json" => serde_json::to_string_pretty(&record_json_with_enrichments(
589 record,
590 current_time,
591 process_ids,
592 ))
593 .map_err(|e| format!("Failed to serialize to JSON: {}", e)),
594 "text" => Ok(format_record_as_text_with_enrichments(
595 record,
596 current_time,
597 process_ids,
598 )),
599 _ => Err(format!("Unknown output format: {}", format)),
600 }
601}
602
603fn sort_records_by_start_time_desc(records: &mut [ExecutionRecord]) {
604 records.sort_by(|a, b| b.start_time.cmp(&a.start_time));
605}
606
607fn indent_block(block: &str, spaces: usize) -> String {
608 let prefix = " ".repeat(spaces);
609 block
610 .lines()
611 .map(|line| format!("{}{}", prefix, line))
612 .collect::<Vec<_>>()
613 .join("\n")
614}
615
616pub fn format_record_list_as_links_notation(records: &[ExecutionRecord]) -> String {
618 let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
619 let process_ids = vec![None; records.len()];
620 format_record_list_as_links_notation_with_current_times(records, ¤t_times, &process_ids)
621}
622
623fn format_record_list_as_links_notation_with_current_times(
624 records: &[ExecutionRecord],
625 current_times: &[Option<String>],
626 process_ids: &[Option<Value>],
627) -> String {
628 let mut lines = vec![
629 "executions".to_string(),
630 format!(" count {}", records.len()),
631 ];
632
633 if records.is_empty() {
634 lines.push(" records ()".to_string());
635 return lines.join("\n");
636 }
637
638 lines.push(" records".to_string());
639 for ((record, current_time), process_ids) in records
640 .iter()
641 .zip(current_times.iter())
642 .zip(process_ids.iter())
643 {
644 let block = format_record_as_links_notation_with_enrichments(
645 record,
646 current_time.as_deref(),
647 process_ids.as_ref(),
648 );
649 lines.push(indent_block(&block, 4));
650 }
651
652 lines.join("\n")
653}
654
655pub fn format_record_list_as_text(records: &[ExecutionRecord]) -> String {
657 let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
658 let process_ids = vec![None; records.len()];
659 format_record_list_as_text_with_current_times(records, ¤t_times, &process_ids)
660}
661
662fn format_record_list_as_text_with_current_times(
663 records: &[ExecutionRecord],
664 current_times: &[Option<String>],
665 process_ids: &[Option<Value>],
666) -> String {
667 let mut lines = vec![
668 "Executions".to_string(),
669 "=".repeat(50),
670 format!("Count: {}", records.len()),
671 ];
672
673 for ((record, current_time), process_ids) in records
674 .iter()
675 .zip(current_times.iter())
676 .zip(process_ids.iter())
677 {
678 lines.push(String::new());
679 lines.push(format_record_as_text_with_enrichments(
680 record,
681 current_time.as_deref(),
682 process_ids.as_ref(),
683 ));
684 }
685
686 lines.join("\n")
687}
688
689fn record_list_json_with_current_times(
690 records: &[ExecutionRecord],
691 current_times: &[Option<String>],
692 process_ids: &[Option<Value>],
693) -> Value {
694 let executions: Vec<Value> = records
695 .iter()
696 .zip(current_times.iter())
697 .zip(process_ids.iter())
698 .map(|((record, current_time), process_ids)| {
699 record_json_with_enrichments(record, current_time.as_deref(), process_ids.as_ref())
700 })
701 .collect();
702
703 serde_json::json!({
704 "count": records.len(),
705 "executions": executions,
706 })
707}
708
709pub fn format_record_list(records: &[ExecutionRecord], format: &str) -> Result<String, String> {
711 let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
712 let process_ids = vec![None; records.len()];
713 format_record_list_with_current_times(records, format, ¤t_times, &process_ids)
714}
715
716fn format_record_list_with_current_times(
717 records: &[ExecutionRecord],
718 format: &str,
719 current_times: &[Option<String>],
720 process_ids: &[Option<Value>],
721) -> Result<String, String> {
722 match format {
723 "links-notation" => Ok(format_record_list_as_links_notation_with_current_times(
724 records,
725 current_times,
726 process_ids,
727 )),
728 "json" => serde_json::to_string_pretty(&record_list_json_with_current_times(
729 records,
730 current_times,
731 process_ids,
732 ))
733 .map_err(|e| format!("Failed to serialize to JSON: {}", e)),
734 "text" => Ok(format_record_list_as_text_with_current_times(
735 records,
736 current_times,
737 process_ids,
738 )),
739 _ => Err(format!("Unknown output format: {}", format)),
740 }
741}
742
743pub struct StatusQueryResult {
745 pub success: bool,
746 pub output: Option<String>,
747 pub error: Option<String>,
748}
749
750pub fn list_executions(
752 store: Option<&ExecutionStore>,
753 output_format: Option<&str>,
754) -> StatusQueryResult {
755 let store = match store {
756 Some(s) => s,
757 None => {
758 return StatusQueryResult {
759 success: false,
760 output: None,
761 error: Some("Execution tracking is disabled.".to_string()),
762 }
763 }
764 };
765
766 let mut records: Vec<ExecutionRecord> =
767 store.get_all().iter().map(enrich_detached_status).collect();
768 sort_records_by_start_time_desc(&mut records);
769 let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
770 let process_ids: Vec<Option<Value>> = records.iter().map(collect_process_ids).collect();
771 let format = output_format.unwrap_or("links-notation");
772
773 match format_record_list_with_current_times(&records, format, ¤t_times, &process_ids) {
774 Ok(output) => StatusQueryResult {
775 success: true,
776 output: Some(output),
777 error: None,
778 },
779 Err(e) => StatusQueryResult {
780 success: false,
781 output: None,
782 error: Some(e),
783 },
784 }
785}
786
787pub fn query_status(
789 store: Option<&ExecutionStore>,
790 identifier: &str,
791 output_format: Option<&str>,
792) -> StatusQueryResult {
793 let store = match store {
794 Some(s) => s,
795 None => {
796 return StatusQueryResult {
797 success: false,
798 output: None,
799 error: Some("Execution tracking is disabled.".to_string()),
800 }
801 }
802 };
803
804 let record = match store.get(identifier) {
805 Some(r) => r,
806 None => {
807 return StatusQueryResult {
808 success: false,
809 output: None,
810 error: Some(format!(
811 "No execution found with UUID or session name: {}",
812 identifier
813 )),
814 }
815 }
816 };
817
818 let enriched = enrich_detached_status(&record);
820 let current_time = attach_current_time(&enriched);
822 let process_ids = collect_process_ids(&enriched);
823
824 let format = output_format.unwrap_or("links-notation");
825 match format_record_with_enrichments(
826 &enriched,
827 format,
828 current_time.as_deref(),
829 process_ids.as_ref(),
830 ) {
831 Ok(output) => StatusQueryResult {
832 success: true,
833 output: Some(output),
834 error: None,
835 },
836 Err(e) => StatusQueryResult {
837 success: false,
838 output: None,
839 error: Some(e),
840 },
841 }
842}
843
844#[cfg(test)]
845mod tests {
846 use super::*;
847 use crate::execution_store::ExecutionRecordOptions;
848 use serde_json::json;
849
850 fn executing_record() -> ExecutionRecord {
851 ExecutionRecord::with_options(ExecutionRecordOptions {
852 command: "sleep 60".to_string(),
853 uuid: Some("issue-126-rust".to_string()),
854 pid: Some(667105),
855 status: Some(ExecutionStatus::Executing),
856 log_path: Some("/tmp/issue-126.log".to_string()),
857 start_time: Some("2026-04-23T10:00:00Z".to_string()),
858 working_directory: Some("/home/user".to_string()),
859 shell: Some("/bin/bash".to_string()),
860 platform: Some("linux".to_string()),
861 ..Default::default()
862 })
863 }
864
865 #[test]
866 fn links_notation_indents_nested_process_id_arrays() {
867 let process_ids = json!({
868 "wrapperPid": 667105,
869 "screenPid": 667120,
870 "commandPids": [667121, 667122],
871 });
872 let output = format_record_with_enrichments(
873 &executing_record(),
874 "links-notation",
875 Some("2026-04-23T10:10:13.042Z"),
876 Some(&process_ids),
877 )
878 .expect("links-notation should format");
879
880 assert!(
881 output.contains(
882 " commandPids\n (\n 667121\n 667122\n )"
883 ),
884 "processIds should be a nested indented block, output: {}",
885 output
886 );
887 assert!(
888 !output.contains("\n(\n"),
889 "opening parenthesis must not start at column 1: {}",
890 output
891 );
892 }
893}