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 read_backend_exit_code(record: &ExecutionRecord) -> Option<i32> {
91 let state = read_docker_state(record)?;
92 if state.running {
93 None
94 } else {
95 state.exit_code
96 }
97}
98
99fn resolve_oom_exit_code(footer_exit: Option<i32>, docker_state: Option<DockerState>) -> i32 {
100 if let Some(code) = footer_exit {
101 return code;
102 }
103 if let Some(state) = docker_state {
104 if let Some(code) = state.exit_code {
105 if !state.running || code != 0 {
106 return code;
107 }
108 }
109 }
110 137
111}
112
113pub fn is_detached_session_alive(record: &ExecutionRecord) -> Option<bool> {
116 let session_name = record.options.get("sessionName")?.as_str()?;
117 let isolation_mode = record.options.get("isolationMode")?.as_str()?;
118 let isolated = record.options.get("isolated")?.as_str()?;
119
120 if isolation_mode != "detached" {
121 return None;
122 }
123
124 match isolated {
125 "screen" => {
126 let output = Command::new("screen").args(["-ls"]).output().ok()?;
127 let stdout = String::from_utf8_lossy(&output.stdout);
128 Some(stdout.contains(session_name))
129 }
130 "tmux" => {
131 let status = Command::new("tmux")
132 .args(["has-session", "-t", session_name])
133 .output()
134 .ok()?;
135 Some(status.status.success())
136 }
137 "docker" => {
138 inspect_docker_state(session_name).map(|state| state.running)
143 }
144 "ssh" => {
145 #[cfg(unix)]
147 {
148 if let Some(pid) = record.pid {
149 let result = unsafe { libc::kill(pid as i32, 0) };
150 Some(result == 0)
151 } else {
152 None
153 }
154 }
155 #[cfg(not(unix))]
156 {
157 let _ = record.pid;
158 None
159 }
160 }
161 _ => None,
162 }
163}
164
165fn read_exit_code_from_log(log_path: &str) -> Option<i32> {
166 let content = fs::read_to_string(log_path).ok()?;
167 content
168 .lines()
169 .rev()
170 .find_map(|line| line.trim().strip_prefix("Exit Code:"))
171 .and_then(|value| value.trim().parse::<i32>().ok())
172}
173
174pub fn enrich_detached_status(record: &ExecutionRecord) -> ExecutionRecord {
179 let footer_exit = read_exit_code_from_log(&record.log_path);
180 let is_detached_docker = is_detached_docker_record(record);
181 let docker_state = if is_detached_docker {
182 read_docker_state(record)
183 } else {
184 None
185 };
186
187 if record.oom_killed == Some(true)
188 || docker_state.and_then(|state| state.oom_killed) == Some(true)
189 {
190 let mut enriched = record.clone();
191 enriched.oom_killed = Some(true);
192 enriched.status = ExecutionStatus::Executed;
193 if enriched.exit_code.is_none() {
194 enriched.exit_code = Some(resolve_oom_exit_code(footer_exit, docker_state));
195 }
196 if enriched.end_time.is_none() {
197 enriched.end_time = Some(chrono::Utc::now().to_rfc3339());
198 }
199 return enriched;
200 }
201
202 let alive = if is_detached_docker {
203 docker_state.map(|state| state.running)
204 } else {
205 is_detached_session_alive(record)
206 };
207
208 let alive = match alive {
209 Some(value) => value,
210 None => {
211 let is_detached =
219 record.options.get("isolationMode").and_then(|v| v.as_str()) == Some("detached");
220 if is_detached && record.status == ExecutionStatus::Executing && footer_exit.is_some() {
221 let mut enriched = record.clone();
222 enriched.status = ExecutionStatus::Executed;
223 enriched.exit_code = footer_exit;
224 if enriched.end_time.is_none() {
225 enriched.end_time = Some(chrono::Utc::now().to_rfc3339());
226 }
227 return enriched;
228 }
229 return record.clone();
230 }
231 };
232
233 let mut enriched = record.clone();
234 if let Some(oom_killed) = docker_state.and_then(|state| state.oom_killed) {
235 enriched.oom_killed = Some(oom_killed);
236 }
237
238 if alive && enriched.status == ExecutionStatus::Executed {
239 if enriched.exit_code.is_none() && footer_exit.is_none() {
246 enriched.status = ExecutionStatus::Executing;
248 enriched.exit_code = None;
249 enriched.end_time = None;
250 }
251 } else if !alive && enriched.status == ExecutionStatus::Executing {
253 enriched.status = ExecutionStatus::Executed;
258 if enriched.exit_code.is_none() {
259 enriched.exit_code = Some(
260 footer_exit
261 .or_else(|| read_backend_exit_code(&enriched))
262 .unwrap_or(-1),
263 );
264 }
265 if enriched.end_time.is_none() {
266 enriched.end_time = Some(chrono::Utc::now().to_rfc3339());
267 }
268 }
269
270 enriched
271}
272
273pub fn attach_current_time(record: &ExecutionRecord) -> Option<String> {
278 if record.status == ExecutionStatus::Executing {
279 Some(chrono::Utc::now().to_rfc3339())
280 } else {
281 None
282 }
283}
284
285pub fn format_record_as_links_notation(record: &ExecutionRecord) -> String {
297 format_record_as_links_notation_with_current_time(record, None)
298}
299
300pub fn format_record_as_links_notation_with_current_time(
303 record: &ExecutionRecord,
304 current_time: Option<&str>,
305) -> String {
306 format_record_as_links_notation_with_enrichments(record, current_time, None)
307}
308
309fn append_links_array(lines: &mut Vec<String>, values: &[Value], indent: usize) {
310 let prefix = " ".repeat(indent);
311 if values.is_empty() {
312 lines.push(format!("{}()", prefix));
313 return;
314 }
315
316 lines.push(format!("{}(", prefix));
317 for value in values {
318 match value {
319 Value::Array(nested) => append_links_array(lines, nested, indent + 2),
320 Value::Object(map) => {
321 for (child_key, child_value) in map {
322 if !child_value.is_null() {
323 append_links_value(lines, child_key, child_value, indent + 2);
324 }
325 }
326 }
327 _ => lines.push(format!(
328 "{}{}",
329 " ".repeat(indent + 2),
330 format_value_for_links_notation(value)
331 )),
332 }
333 }
334 lines.push(format!("{})", prefix));
335}
336
337fn append_links_value(lines: &mut Vec<String>, key: &str, value: &Value, indent: usize) {
338 let prefix = " ".repeat(indent);
339 match value {
340 Value::Object(map) => {
341 if map.is_empty() {
342 return;
343 }
344 lines.push(format!("{}{}", prefix, key));
345 for (child_key, child_value) in map {
346 if !child_value.is_null() {
347 append_links_value(lines, child_key, child_value, indent + 4);
348 }
349 }
350 }
351 Value::Array(values) => {
352 lines.push(format!("{}{}", prefix, key));
353 append_links_array(lines, values, indent + 2);
354 }
355 _ => lines.push(format!(
356 "{}{} {}",
357 prefix,
358 key,
359 format_value_for_links_notation(value)
360 )),
361 }
362}
363
364fn format_record_as_links_notation_with_enrichments(
365 record: &ExecutionRecord,
366 current_time: Option<&str>,
367 process_ids: Option<&Value>,
368) -> String {
369 let json = record.to_json();
370 let mut lines = vec![record.uuid.clone()];
371
372 if let Value::Object(map) = json {
373 for (key, value) in map {
374 if !value.is_null() {
375 if key == "options" {
376 if let Value::Object(opts) = &value {
378 if !opts.is_empty() {
379 lines.push(" options".to_string());
380 for (opt_key, opt_value) in opts {
381 if !opt_value.is_null() {
382 let formatted = format_value_for_links_notation(opt_value);
383 lines.push(format!(" {} {}", opt_key, formatted));
384 }
385 }
386 }
387 }
388 } else {
389 let formatted_value = match &value {
390 Value::String(s) => escape_for_links_notation(s),
391 Value::Bool(b) => b.to_string(),
392 Value::Number(n) => n.to_string(),
393 Value::Null => "null".to_string(),
394 Value::Object(_) | Value::Array(_) => {
395 format_value_for_links_notation(&value)
397 }
398 };
399 lines.push(format!(" {} {}", key, formatted_value));
400 }
401 }
402
403 if key == "pid" {
406 if let Some(process_ids) = process_ids {
407 append_links_value(&mut lines, "processIds", process_ids, 2);
408 }
409 }
410
411 if key == "startTime" {
413 if let Some(ct) = current_time {
414 lines.push(format!(" currentTime {}", escape_for_links_notation(ct)));
415 }
416 }
417 }
418 }
419
420 lines.join("\n")
421}
422
423pub fn format_record_as_text(record: &ExecutionRecord) -> String {
425 format_record_as_text_with_current_time(record, None)
426}
427
428pub fn format_record_as_text_with_current_time(
431 record: &ExecutionRecord,
432 current_time: Option<&str>,
433) -> String {
434 format_record_as_text_with_enrichments(record, current_time, None)
435}
436
437fn append_text_process_ids(lines: &mut Vec<String>, process_ids: &Value) {
438 let Value::Object(map) = process_ids else {
439 return;
440 };
441 if map.is_empty() {
442 return;
443 }
444
445 lines.push("Process IDs:".to_string());
446 for (key, value) in map {
447 let value_str = match value {
448 Value::String(s) => s.clone(),
449 Value::Bool(b) => b.to_string(),
450 Value::Number(n) => n.to_string(),
451 Value::Null => "null".to_string(),
452 other => serde_json::to_string(other).unwrap_or_default(),
453 };
454 lines.push(format!(" {}: {}", key, value_str));
455 }
456}
457
458fn format_record_as_text_with_enrichments(
459 record: &ExecutionRecord,
460 current_time: Option<&str>,
461 process_ids: Option<&Value>,
462) -> String {
463 let exit_code_str = record
464 .exit_code
465 .map(|c| c.to_string())
466 .unwrap_or_else(|| "N/A".to_string());
467 let pid_str = record
468 .pid
469 .map(|p| p.to_string())
470 .unwrap_or_else(|| "N/A".to_string());
471 let end_time_str = record.end_time.as_deref().unwrap_or("N/A");
472
473 let mut lines = vec![
474 "Execution Status".to_string(),
475 "=".repeat(50),
476 format!("UUID: {}", record.uuid),
477 format!("Status: {}", record.status),
478 format!("Command: {}", record.command),
479 format!("Exit Code: {}", exit_code_str),
480 ];
481 if let Some(oom_killed) = record.oom_killed {
482 lines.push(format!("OOM Killed: {}", oom_killed));
483 }
484 lines.push(format!("PID: {}", pid_str));
485 if let Some(process_ids) = process_ids {
486 append_text_process_ids(&mut lines, process_ids);
487 }
488 lines.extend([
489 format!("Working Directory: {}", record.working_directory),
490 format!("Shell: {}", record.shell),
491 format!("Platform: {}", record.platform),
492 format!("Start Time: {}", record.start_time),
493 ]);
494 if let Some(ct) = current_time {
495 lines.push(format!("Current Time: {}", ct));
496 }
497 lines.push(format!("End Time: {}", end_time_str));
498 lines.push(format!("Log Path: {}", record.log_path));
499
500 if !record.options.is_empty() {
502 lines.push("Options:".to_string());
503 for (key, value) in &record.options {
504 let value_str = match value {
505 Value::String(s) => s.clone(),
506 Value::Bool(b) => b.to_string(),
507 Value::Number(n) => n.to_string(),
508 Value::Null => "null".to_string(),
509 other => serde_json::to_string(other).unwrap_or_default(),
510 };
511 lines.push(format!(" {}: {}", key, value_str));
512 }
513 }
514
515 lines.join("\n")
516}
517
518fn record_json_with_enrichments(
519 record: &ExecutionRecord,
520 current_time: Option<&str>,
521 process_ids: Option<&Value>,
522) -> Value {
523 let mut json = record.to_json();
524 if let Value::Object(map) = &mut json {
525 if let Some(process_ids) = process_ids {
526 map.insert("processIds".to_string(), process_ids.clone());
527 }
528 if let Some(ct) = current_time {
529 map.insert("currentTime".to_string(), Value::String(ct.to_string()));
530 }
531 }
532 json
533}
534
535pub fn format_record(record: &ExecutionRecord, format: &str) -> Result<String, String> {
537 format_record_with_current_time(record, format, None)
538}
539
540pub fn format_record_with_current_time(
544 record: &ExecutionRecord,
545 format: &str,
546 current_time: Option<&str>,
547) -> Result<String, String> {
548 format_record_with_enrichments(record, format, current_time, None)
549}
550
551fn format_record_with_enrichments(
552 record: &ExecutionRecord,
553 format: &str,
554 current_time: Option<&str>,
555 process_ids: Option<&Value>,
556) -> Result<String, String> {
557 match format {
558 "links-notation" => Ok(format_record_as_links_notation_with_enrichments(
559 record,
560 current_time,
561 process_ids,
562 )),
563 "json" => serde_json::to_string_pretty(&record_json_with_enrichments(
564 record,
565 current_time,
566 process_ids,
567 ))
568 .map_err(|e| format!("Failed to serialize to JSON: {}", e)),
569 "text" => Ok(format_record_as_text_with_enrichments(
570 record,
571 current_time,
572 process_ids,
573 )),
574 _ => Err(format!("Unknown output format: {}", format)),
575 }
576}
577
578fn sort_records_by_start_time_desc(records: &mut [ExecutionRecord]) {
579 records.sort_by(|a, b| b.start_time.cmp(&a.start_time));
580}
581
582fn indent_block(block: &str, spaces: usize) -> String {
583 let prefix = " ".repeat(spaces);
584 block
585 .lines()
586 .map(|line| format!("{}{}", prefix, line))
587 .collect::<Vec<_>>()
588 .join("\n")
589}
590
591pub fn format_record_list_as_links_notation(records: &[ExecutionRecord]) -> String {
593 let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
594 let process_ids = vec![None; records.len()];
595 format_record_list_as_links_notation_with_current_times(records, ¤t_times, &process_ids)
596}
597
598fn format_record_list_as_links_notation_with_current_times(
599 records: &[ExecutionRecord],
600 current_times: &[Option<String>],
601 process_ids: &[Option<Value>],
602) -> String {
603 let mut lines = vec![
604 "executions".to_string(),
605 format!(" count {}", records.len()),
606 ];
607
608 if records.is_empty() {
609 lines.push(" records ()".to_string());
610 return lines.join("\n");
611 }
612
613 lines.push(" records".to_string());
614 for ((record, current_time), process_ids) in records
615 .iter()
616 .zip(current_times.iter())
617 .zip(process_ids.iter())
618 {
619 let block = format_record_as_links_notation_with_enrichments(
620 record,
621 current_time.as_deref(),
622 process_ids.as_ref(),
623 );
624 lines.push(indent_block(&block, 4));
625 }
626
627 lines.join("\n")
628}
629
630pub fn format_record_list_as_text(records: &[ExecutionRecord]) -> String {
632 let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
633 let process_ids = vec![None; records.len()];
634 format_record_list_as_text_with_current_times(records, ¤t_times, &process_ids)
635}
636
637fn format_record_list_as_text_with_current_times(
638 records: &[ExecutionRecord],
639 current_times: &[Option<String>],
640 process_ids: &[Option<Value>],
641) -> String {
642 let mut lines = vec![
643 "Executions".to_string(),
644 "=".repeat(50),
645 format!("Count: {}", records.len()),
646 ];
647
648 for ((record, current_time), process_ids) in records
649 .iter()
650 .zip(current_times.iter())
651 .zip(process_ids.iter())
652 {
653 lines.push(String::new());
654 lines.push(format_record_as_text_with_enrichments(
655 record,
656 current_time.as_deref(),
657 process_ids.as_ref(),
658 ));
659 }
660
661 lines.join("\n")
662}
663
664fn record_list_json_with_current_times(
665 records: &[ExecutionRecord],
666 current_times: &[Option<String>],
667 process_ids: &[Option<Value>],
668) -> Value {
669 let executions: Vec<Value> = records
670 .iter()
671 .zip(current_times.iter())
672 .zip(process_ids.iter())
673 .map(|((record, current_time), process_ids)| {
674 record_json_with_enrichments(record, current_time.as_deref(), process_ids.as_ref())
675 })
676 .collect();
677
678 serde_json::json!({
679 "count": records.len(),
680 "executions": executions,
681 })
682}
683
684pub fn format_record_list(records: &[ExecutionRecord], format: &str) -> Result<String, String> {
686 let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
687 let process_ids = vec![None; records.len()];
688 format_record_list_with_current_times(records, format, ¤t_times, &process_ids)
689}
690
691fn format_record_list_with_current_times(
692 records: &[ExecutionRecord],
693 format: &str,
694 current_times: &[Option<String>],
695 process_ids: &[Option<Value>],
696) -> Result<String, String> {
697 match format {
698 "links-notation" => Ok(format_record_list_as_links_notation_with_current_times(
699 records,
700 current_times,
701 process_ids,
702 )),
703 "json" => serde_json::to_string_pretty(&record_list_json_with_current_times(
704 records,
705 current_times,
706 process_ids,
707 ))
708 .map_err(|e| format!("Failed to serialize to JSON: {}", e)),
709 "text" => Ok(format_record_list_as_text_with_current_times(
710 records,
711 current_times,
712 process_ids,
713 )),
714 _ => Err(format!("Unknown output format: {}", format)),
715 }
716}
717
718pub struct StatusQueryResult {
720 pub success: bool,
721 pub output: Option<String>,
722 pub error: Option<String>,
723}
724
725pub fn list_executions(
727 store: Option<&ExecutionStore>,
728 output_format: Option<&str>,
729) -> StatusQueryResult {
730 let store = match store {
731 Some(s) => s,
732 None => {
733 return StatusQueryResult {
734 success: false,
735 output: None,
736 error: Some("Execution tracking is disabled.".to_string()),
737 }
738 }
739 };
740
741 let mut records: Vec<ExecutionRecord> =
742 store.get_all().iter().map(enrich_detached_status).collect();
743 sort_records_by_start_time_desc(&mut records);
744 let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
745 let process_ids: Vec<Option<Value>> = records.iter().map(collect_process_ids).collect();
746 let format = output_format.unwrap_or("links-notation");
747
748 match format_record_list_with_current_times(&records, format, ¤t_times, &process_ids) {
749 Ok(output) => StatusQueryResult {
750 success: true,
751 output: Some(output),
752 error: None,
753 },
754 Err(e) => StatusQueryResult {
755 success: false,
756 output: None,
757 error: Some(e),
758 },
759 }
760}
761
762pub fn query_status(
764 store: Option<&ExecutionStore>,
765 identifier: &str,
766 output_format: Option<&str>,
767) -> StatusQueryResult {
768 let store = match store {
769 Some(s) => s,
770 None => {
771 return StatusQueryResult {
772 success: false,
773 output: None,
774 error: Some("Execution tracking is disabled.".to_string()),
775 }
776 }
777 };
778
779 let record = match store.get(identifier) {
780 Some(r) => r,
781 None => {
782 return StatusQueryResult {
783 success: false,
784 output: None,
785 error: Some(format!(
786 "No execution found with UUID or session name: {}",
787 identifier
788 )),
789 }
790 }
791 };
792
793 let enriched = enrich_detached_status(&record);
795 let current_time = attach_current_time(&enriched);
797 let process_ids = collect_process_ids(&enriched);
798
799 let format = output_format.unwrap_or("links-notation");
800 match format_record_with_enrichments(
801 &enriched,
802 format,
803 current_time.as_deref(),
804 process_ids.as_ref(),
805 ) {
806 Ok(output) => StatusQueryResult {
807 success: true,
808 output: Some(output),
809 error: None,
810 },
811 Err(e) => StatusQueryResult {
812 success: false,
813 output: None,
814 error: Some(e),
815 },
816 }
817}
818
819#[cfg(test)]
820mod tests {
821 use super::*;
822 use crate::execution_store::ExecutionRecordOptions;
823 use serde_json::json;
824
825 fn executing_record() -> ExecutionRecord {
826 ExecutionRecord::with_options(ExecutionRecordOptions {
827 command: "sleep 60".to_string(),
828 uuid: Some("issue-126-rust".to_string()),
829 pid: Some(667105),
830 status: Some(ExecutionStatus::Executing),
831 log_path: Some("/tmp/issue-126.log".to_string()),
832 start_time: Some("2026-04-23T10:00:00Z".to_string()),
833 working_directory: Some("/home/user".to_string()),
834 shell: Some("/bin/bash".to_string()),
835 platform: Some("linux".to_string()),
836 ..Default::default()
837 })
838 }
839
840 #[test]
841 fn links_notation_indents_nested_process_id_arrays() {
842 let process_ids = json!({
843 "wrapperPid": 667105,
844 "screenPid": 667120,
845 "commandPids": [667121, 667122],
846 });
847 let output = format_record_with_enrichments(
848 &executing_record(),
849 "links-notation",
850 Some("2026-04-23T10:10:13.042Z"),
851 Some(&process_ids),
852 )
853 .expect("links-notation should format");
854
855 assert!(
856 output.contains(
857 " commandPids\n (\n 667121\n 667122\n )"
858 ),
859 "processIds should be a nested indented block, output: {}",
860 output
861 );
862 assert!(
863 !output.contains("\n(\n"),
864 "opening parenthesis must not start at column 1: {}",
865 output
866 );
867 }
868}