1use crate::execution_control::collect_process_ids;
9use crate::execution_store::{ExecutionRecord, ExecutionStatus, ExecutionStore};
10use crate::exit_reason::{
11 describe_exit_code, resolve_exit_reason, resolve_memory_exhaustion, CGROUP_OOM_EXIT_REASON,
12};
13use crate::isolation::isolation_log::{read_log_tail, FATAL_MARKER_TAIL_BYTES};
14use crate::output_blocks::{escape_for_links_notation, format_value_for_links_notation};
15use crate::status_footer::read_footer_from_log;
16use crate::status_probe::{
17 apply_end_time, backend_exit_code, is_detached_docker_record, is_detached_session_alive,
18 read_docker_state, resolve_oom_observation,
19};
20use chrono::Utc;
21use serde_json::Value;
22
23pub fn read_exit_code_from_log(log_path: &str) -> Option<i32> {
29 read_footer_from_log(log_path).exit_code
30}
31
32pub fn enrich_detached_status(record: &ExecutionRecord) -> ExecutionRecord {
41 let mut enriched = resolve_detached_status(record);
42 if enriched.status != ExecutionStatus::Executed {
43 return enriched;
44 }
45 let tail = read_log_tail(&enriched.log_path, FATAL_MARKER_TAIL_BYTES);
46 let oom_kills = enriched.cgroup_memory.and_then(|memory| memory.oom_kills);
47 if enriched.exit_reason.is_none()
50 || enriched.exit_reason.as_deref() == Some(CGROUP_OOM_EXIT_REASON)
51 {
52 enriched.exit_reason = resolve_exit_reason(
53 enriched.exit_code,
54 tail.as_deref(),
55 enriched.oom_killed,
56 oom_kills,
57 );
58 }
59 if enriched.memory_exhausted.is_none() {
60 if let Some(memory) = resolve_memory_exhaustion(
61 enriched.exit_code,
62 tail.as_deref(),
63 enriched.oom_killed,
64 oom_kills,
65 ) {
66 enriched.memory_exhausted = Some(memory.memory_exhausted);
67 enriched.memory_exhausted_reason = Some(memory.memory_exhausted_reason);
68 }
69 }
70 enriched
71}
72
73fn resolve_detached_status(record: &ExecutionRecord) -> ExecutionRecord {
78 let footer = read_footer_from_log(&record.log_path);
79 let footer_exit = footer.exit_code;
80 let is_detached_docker = is_detached_docker_record(record);
81 let docker_state = if is_detached_docker {
82 read_docker_state(record)
83 } else {
84 None
85 };
86
87 let oom_killed = resolve_oom_observation(record, docker_state.as_ref());
89 let clone_record = || {
90 let mut enriched = record.clone();
91 if oom_killed.is_some() {
92 enriched.oom_killed = oom_killed;
93 }
94 enriched
95 };
96
97 let alive = if is_detached_docker {
98 docker_state.as_ref().map(|state| state.running)
99 } else {
100 is_detached_session_alive(record)
101 };
102
103 let alive = match alive {
104 Some(value) => value,
105 None => {
106 let is_detached =
114 record.options.get("isolationMode").and_then(|v| v.as_str()) == Some("detached");
115 if is_detached && record.status == ExecutionStatus::Executing {
116 if footer_exit.is_some() {
117 let mut enriched = clone_record();
118 enriched.status = ExecutionStatus::Executed;
119 enriched.exit_code = footer_exit;
120 apply_end_time(&mut enriched, docker_state.as_ref(), &footer);
121 return enriched;
122 }
123 if oom_killed == Some(true) {
124 let mut enriched = clone_record();
131 enriched.status = ExecutionStatus::Executed;
132 enriched.exit_code = Some(137);
133 apply_end_time(&mut enriched, docker_state.as_ref(), &footer);
134 return enriched;
135 }
136 }
137 return clone_record();
138 }
139 };
140
141 let mut enriched = clone_record();
142
143 if alive && enriched.status == ExecutionStatus::Executed {
144 if enriched.exit_code.is_none() && footer_exit.is_none() {
151 enriched.status = ExecutionStatus::Executing;
153 enriched.exit_code = None;
154 enriched.end_time = None;
155 enriched.end_time_source = None;
156 }
157 } else if !alive && enriched.status == ExecutionStatus::Executing {
159 enriched.status = ExecutionStatus::Executed;
167 if enriched.exit_code.is_none() {
168 enriched.exit_code = Some(
169 backend_exit_code(docker_state.as_ref())
170 .or(footer_exit)
171 .or(if oom_killed == Some(true) {
172 Some(137)
173 } else {
174 None
175 })
176 .unwrap_or(-1),
177 );
178 }
179 apply_end_time(&mut enriched, docker_state.as_ref(), &footer);
180 }
181
182 enriched
183}
184
185pub fn attach_current_time(record: &ExecutionRecord) -> Option<String> {
190 if record.status == ExecutionStatus::Executing {
191 Some(Utc::now().to_rfc3339())
192 } else {
193 None
194 }
195}
196
197pub fn format_record_as_links_notation(record: &ExecutionRecord) -> String {
209 format_record_as_links_notation_with_current_time(record, None)
210}
211
212pub fn format_record_as_links_notation_with_current_time(
215 record: &ExecutionRecord,
216 current_time: Option<&str>,
217) -> String {
218 format_record_as_links_notation_with_enrichments(record, current_time, None)
219}
220
221fn append_links_array(lines: &mut Vec<String>, values: &[Value], indent: usize) {
222 let prefix = " ".repeat(indent);
223 if values.is_empty() {
224 lines.push(format!("{}()", prefix));
225 return;
226 }
227
228 lines.push(format!("{}(", prefix));
229 for value in values {
230 match value {
231 Value::Array(nested) => append_links_array(lines, nested, indent + 2),
232 Value::Object(map) => {
233 for (child_key, child_value) in map {
234 if !child_value.is_null() {
235 append_links_value(lines, child_key, child_value, indent + 2);
236 }
237 }
238 }
239 _ => lines.push(format!(
240 "{}{}",
241 " ".repeat(indent + 2),
242 format_value_for_links_notation(value)
243 )),
244 }
245 }
246 lines.push(format!("{})", prefix));
247}
248
249fn append_links_value(lines: &mut Vec<String>, key: &str, value: &Value, indent: usize) {
250 let prefix = " ".repeat(indent);
251 match value {
252 Value::Object(map) => {
253 if map.is_empty() {
254 return;
255 }
256 lines.push(format!("{}{}", prefix, key));
257 for (child_key, child_value) in map {
258 if !child_value.is_null() {
259 append_links_value(lines, child_key, child_value, indent + 4);
260 }
261 }
262 }
263 Value::Array(values) => {
264 lines.push(format!("{}{}", prefix, key));
265 append_links_array(lines, values, indent + 2);
266 }
267 _ => lines.push(format!(
268 "{}{} {}",
269 prefix,
270 key,
271 format_value_for_links_notation(value)
272 )),
273 }
274}
275
276fn format_record_as_links_notation_with_enrichments(
277 record: &ExecutionRecord,
278 current_time: Option<&str>,
279 process_ids: Option<&Value>,
280) -> String {
281 let json = record.to_json();
282 let mut lines = vec![record.uuid.clone()];
283
284 if let Value::Object(map) = json {
285 for (key, value) in map {
286 if !value.is_null() {
287 if key == "options" {
288 if let Value::Object(opts) = &value {
290 if !opts.is_empty() {
291 lines.push(" options".to_string());
292 for (opt_key, opt_value) in opts {
293 if !opt_value.is_null() {
294 let formatted = format_value_for_links_notation(opt_value);
295 lines.push(format!(" {} {}", opt_key, formatted));
296 }
297 }
298 }
299 }
300 } else if value.is_object() {
301 append_links_value(&mut lines, &key, &value, 2);
304 } else {
305 let formatted_value = match &value {
306 Value::String(s) => escape_for_links_notation(s),
307 Value::Bool(b) => b.to_string(),
308 Value::Number(n) => n.to_string(),
309 Value::Null => "null".to_string(),
310 Value::Object(_) | Value::Array(_) => {
311 format_value_for_links_notation(&value)
313 }
314 };
315 lines.push(format!(" {} {}", key, formatted_value));
316 }
317 }
318
319 if key == "pid" {
322 if let Some(process_ids) = process_ids {
323 append_links_value(&mut lines, "processIds", process_ids, 2);
324 }
325 }
326
327 if key == "startTime" {
329 if let Some(ct) = current_time {
330 lines.push(format!(" currentTime {}", escape_for_links_notation(ct)));
331 }
332 }
333 }
334 }
335
336 lines.join("\n")
337}
338
339pub fn format_record_as_text(record: &ExecutionRecord) -> String {
341 format_record_as_text_with_current_time(record, None)
342}
343
344pub fn format_record_as_text_with_current_time(
347 record: &ExecutionRecord,
348 current_time: Option<&str>,
349) -> String {
350 format_record_as_text_with_enrichments(record, current_time, None)
351}
352
353fn append_text_process_ids(lines: &mut Vec<String>, process_ids: &Value) {
354 let Value::Object(map) = process_ids else {
355 return;
356 };
357 if map.is_empty() {
358 return;
359 }
360
361 lines.push("Process IDs:".to_string());
362 for (key, value) in map {
363 let value_str = match value {
364 Value::String(s) => s.clone(),
365 Value::Bool(b) => b.to_string(),
366 Value::Number(n) => n.to_string(),
367 Value::Null => "null".to_string(),
368 other => serde_json::to_string(other).unwrap_or_default(),
369 };
370 lines.push(format!(" {}: {}", key, value_str));
371 }
372}
373
374fn format_record_as_text_with_enrichments(
375 record: &ExecutionRecord,
376 current_time: Option<&str>,
377 process_ids: Option<&Value>,
378) -> String {
379 let exit_code_str = record
380 .exit_code
381 .map(|c| describe_exit_code(Some(c)).text)
385 .unwrap_or_else(|| "N/A".to_string());
386 let pid_str = record
387 .pid
388 .map(|p| p.to_string())
389 .unwrap_or_else(|| "N/A".to_string());
390 let end_time_str = record.end_time.as_deref().unwrap_or("N/A");
391
392 let mut lines = vec![
393 "Execution Status".to_string(),
394 "=".repeat(50),
395 format!("UUID: {}", record.uuid),
396 format!("Status: {}", record.status),
397 format!("Command: {}", record.command),
398 format!("Exit Code: {}", exit_code_str),
399 ];
400 if let Some(oom_killed) = record.oom_killed {
401 lines.push(format!("OOM Killed: {}", oom_killed));
402 }
403 if let Some(ref exit_reason) = record.exit_reason {
404 lines.push(format!("Exit Reason: {}", exit_reason));
405 }
406 if let Some(memory_exhausted) = record.memory_exhausted {
407 lines.push(format!("Memory Exhausted: {}", memory_exhausted));
408 }
409 if let Some(ref reason) = record.memory_exhausted_reason {
410 lines.push(format!("Memory Evidence: {}", reason));
411 }
412 if let Some(ref memory) = record.cgroup_memory {
414 lines.push(format!(
415 "Cgroup Memory: {}",
416 crate::cgroup_memory::format_cgroup_memory(memory)
417 ));
418 }
419 lines.push(format!("PID: {}", pid_str));
420 if let Some(process_ids) = process_ids {
421 append_text_process_ids(&mut lines, process_ids);
422 }
423 lines.extend([
424 format!("Working Directory: {}", record.working_directory),
425 format!("Shell: {}", record.shell),
426 format!("Platform: {}", record.platform),
427 format!("Start Time: {}", record.start_time),
428 ]);
429 if let Some(ct) = current_time {
430 lines.push(format!("Current Time: {}", ct));
431 }
432 lines.push(format!("End Time: {}", end_time_str));
433 if let Some(ref source) = record.end_time_source {
434 lines.push(format!("End Time Source: {}", source));
437 }
438 if let Some(ref started) = record.container_started_at {
439 lines.push(format!("Container Started: {}", started));
440 }
441 if let Some(ref detected) = record.stale_detected_at {
442 lines.push(format!("Stale Detected At: {}", detected));
443 }
444 lines.push(format!("Log Path: {}", record.log_path));
445
446 if !record.options.is_empty() {
448 lines.push("Options:".to_string());
449 for (key, value) in &record.options {
450 let value_str = match value {
451 Value::String(s) => s.clone(),
452 Value::Bool(b) => b.to_string(),
453 Value::Number(n) => n.to_string(),
454 Value::Null => "null".to_string(),
455 other => serde_json::to_string(other).unwrap_or_default(),
456 };
457 lines.push(format!(" {}: {}", key, value_str));
458 }
459 }
460
461 lines.join("\n")
462}
463
464fn record_json_with_enrichments(
465 record: &ExecutionRecord,
466 current_time: Option<&str>,
467 process_ids: Option<&Value>,
468) -> Value {
469 let mut json = record.to_json();
470 if let Value::Object(map) = &mut json {
471 if let Some(process_ids) = process_ids {
472 map.insert("processIds".to_string(), process_ids.clone());
473 }
474 if let Some(ct) = current_time {
475 map.insert("currentTime".to_string(), Value::String(ct.to_string()));
476 }
477 }
478 json
479}
480
481pub fn format_record(record: &ExecutionRecord, format: &str) -> Result<String, String> {
483 format_record_with_current_time(record, format, None)
484}
485
486pub fn format_record_with_current_time(
490 record: &ExecutionRecord,
491 format: &str,
492 current_time: Option<&str>,
493) -> Result<String, String> {
494 format_record_with_enrichments(record, format, current_time, None)
495}
496
497fn format_record_with_enrichments(
498 record: &ExecutionRecord,
499 format: &str,
500 current_time: Option<&str>,
501 process_ids: Option<&Value>,
502) -> Result<String, String> {
503 match format {
504 "links-notation" => Ok(format_record_as_links_notation_with_enrichments(
505 record,
506 current_time,
507 process_ids,
508 )),
509 "json" => serde_json::to_string_pretty(&record_json_with_enrichments(
510 record,
511 current_time,
512 process_ids,
513 ))
514 .map_err(|e| format!("Failed to serialize to JSON: {}", e)),
515 "text" => Ok(format_record_as_text_with_enrichments(
516 record,
517 current_time,
518 process_ids,
519 )),
520 _ => Err(format!("Unknown output format: {}", format)),
521 }
522}
523
524fn sort_records_by_start_time_desc(records: &mut [ExecutionRecord]) {
525 records.sort_by(|a, b| b.start_time.cmp(&a.start_time));
526}
527
528fn indent_block(block: &str, spaces: usize) -> String {
529 let prefix = " ".repeat(spaces);
530 block
531 .lines()
532 .map(|line| format!("{}{}", prefix, line))
533 .collect::<Vec<_>>()
534 .join("\n")
535}
536
537pub fn format_record_list_as_links_notation(records: &[ExecutionRecord]) -> String {
539 let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
540 let process_ids = vec![None; records.len()];
541 format_record_list_as_links_notation_with_current_times(records, ¤t_times, &process_ids)
542}
543
544fn format_record_list_as_links_notation_with_current_times(
545 records: &[ExecutionRecord],
546 current_times: &[Option<String>],
547 process_ids: &[Option<Value>],
548) -> String {
549 let mut lines = vec![
550 "executions".to_string(),
551 format!(" count {}", records.len()),
552 ];
553
554 if records.is_empty() {
555 lines.push(" records ()".to_string());
556 return lines.join("\n");
557 }
558
559 lines.push(" records".to_string());
560 for ((record, current_time), process_ids) in records
561 .iter()
562 .zip(current_times.iter())
563 .zip(process_ids.iter())
564 {
565 let block = format_record_as_links_notation_with_enrichments(
566 record,
567 current_time.as_deref(),
568 process_ids.as_ref(),
569 );
570 lines.push(indent_block(&block, 4));
571 }
572
573 lines.join("\n")
574}
575
576pub fn format_record_list_as_text(records: &[ExecutionRecord]) -> String {
578 let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
579 let process_ids = vec![None; records.len()];
580 format_record_list_as_text_with_current_times(records, ¤t_times, &process_ids)
581}
582
583fn format_record_list_as_text_with_current_times(
584 records: &[ExecutionRecord],
585 current_times: &[Option<String>],
586 process_ids: &[Option<Value>],
587) -> String {
588 let mut lines = vec![
589 "Executions".to_string(),
590 "=".repeat(50),
591 format!("Count: {}", records.len()),
592 ];
593
594 for ((record, current_time), process_ids) in records
595 .iter()
596 .zip(current_times.iter())
597 .zip(process_ids.iter())
598 {
599 lines.push(String::new());
600 lines.push(format_record_as_text_with_enrichments(
601 record,
602 current_time.as_deref(),
603 process_ids.as_ref(),
604 ));
605 }
606
607 lines.join("\n")
608}
609
610fn record_list_json_with_current_times(
611 records: &[ExecutionRecord],
612 current_times: &[Option<String>],
613 process_ids: &[Option<Value>],
614) -> Value {
615 let executions: Vec<Value> = records
616 .iter()
617 .zip(current_times.iter())
618 .zip(process_ids.iter())
619 .map(|((record, current_time), process_ids)| {
620 record_json_with_enrichments(record, current_time.as_deref(), process_ids.as_ref())
621 })
622 .collect();
623
624 serde_json::json!({
625 "count": records.len(),
626 "executions": executions,
627 })
628}
629
630pub fn format_record_list(records: &[ExecutionRecord], format: &str) -> Result<String, 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_with_current_times(records, format, ¤t_times, &process_ids)
635}
636
637fn format_record_list_with_current_times(
638 records: &[ExecutionRecord],
639 format: &str,
640 current_times: &[Option<String>],
641 process_ids: &[Option<Value>],
642) -> Result<String, String> {
643 match format {
644 "links-notation" => Ok(format_record_list_as_links_notation_with_current_times(
645 records,
646 current_times,
647 process_ids,
648 )),
649 "json" => serde_json::to_string_pretty(&record_list_json_with_current_times(
650 records,
651 current_times,
652 process_ids,
653 ))
654 .map_err(|e| format!("Failed to serialize to JSON: {}", e)),
655 "text" => Ok(format_record_list_as_text_with_current_times(
656 records,
657 current_times,
658 process_ids,
659 )),
660 _ => Err(format!("Unknown output format: {}", format)),
661 }
662}
663
664pub struct StatusQueryResult {
666 pub success: bool,
667 pub output: Option<String>,
668 pub error: Option<String>,
669}
670
671pub fn list_executions(
673 store: Option<&ExecutionStore>,
674 output_format: Option<&str>,
675) -> StatusQueryResult {
676 list_executions_filtered(store, output_format, false)
677}
678
679pub fn list_executions_filtered(
685 store: Option<&ExecutionStore>,
686 output_format: Option<&str>,
687 running_only: bool,
688) -> StatusQueryResult {
689 let store = match store {
690 Some(s) => s,
691 None => {
692 return StatusQueryResult {
693 success: false,
694 output: None,
695 error: Some("Execution tracking is disabled.".to_string()),
696 }
697 }
698 };
699
700 let mut records: Vec<ExecutionRecord> =
701 store.get_all().iter().map(enrich_detached_status).collect();
702 if running_only {
703 records.retain(|record| record.status == ExecutionStatus::Executing);
704 }
705 sort_records_by_start_time_desc(&mut records);
706 let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
707 let process_ids: Vec<Option<Value>> = records.iter().map(collect_process_ids).collect();
708 let format = output_format.unwrap_or("links-notation");
709
710 match format_record_list_with_current_times(&records, format, ¤t_times, &process_ids) {
711 Ok(output) => StatusQueryResult {
712 success: true,
713 output: Some(output),
714 error: None,
715 },
716 Err(e) => StatusQueryResult {
717 success: false,
718 output: None,
719 error: Some(e),
720 },
721 }
722}
723
724pub fn query_status(
726 store: Option<&ExecutionStore>,
727 identifier: &str,
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 record = match store.get(identifier) {
742 Some(r) => r,
743 None => {
744 return StatusQueryResult {
745 success: false,
746 output: None,
747 error: Some(format!(
748 "No execution found with UUID or session name: {}",
749 identifier
750 )),
751 }
752 }
753 };
754
755 let enriched = enrich_detached_status(&record);
757 let current_time = attach_current_time(&enriched);
759 let process_ids = collect_process_ids(&enriched);
760
761 let format = output_format.unwrap_or("links-notation");
762 match format_record_with_enrichments(
763 &enriched,
764 format,
765 current_time.as_deref(),
766 process_ids.as_ref(),
767 ) {
768 Ok(output) => StatusQueryResult {
769 success: true,
770 output: Some(output),
771 error: None,
772 },
773 Err(e) => StatusQueryResult {
774 success: false,
775 output: None,
776 error: Some(e),
777 },
778 }
779}
780
781#[cfg(test)]
782mod tests {
783 use super::*;
784 use crate::execution_store::ExecutionRecordOptions;
785 use serde_json::json;
786
787 fn executing_record() -> ExecutionRecord {
788 ExecutionRecord::with_options(ExecutionRecordOptions {
789 command: "sleep 60".to_string(),
790 uuid: Some("issue-126-rust".to_string()),
791 pid: Some(667105),
792 status: Some(ExecutionStatus::Executing),
793 log_path: Some("/tmp/issue-126.log".to_string()),
794 start_time: Some("2026-04-23T10:00:00Z".to_string()),
795 working_directory: Some("/home/user".to_string()),
796 shell: Some("/bin/bash".to_string()),
797 platform: Some("linux".to_string()),
798 ..Default::default()
799 })
800 }
801
802 #[test]
803 fn links_notation_indents_nested_process_id_arrays() {
804 let process_ids = json!({
805 "wrapperPid": 667105,
806 "screenPid": 667120,
807 "commandPids": [667121, 667122],
808 });
809 let output = format_record_with_enrichments(
810 &executing_record(),
811 "links-notation",
812 Some("2026-04-23T10:10:13.042Z"),
813 Some(&process_ids),
814 )
815 .expect("links-notation should format");
816
817 assert!(
818 output.contains(
819 " commandPids\n (\n 667121\n 667122\n )"
820 ),
821 "processIds should be a nested indented block, output: {}",
822 output
823 );
824 assert!(
825 !output.contains("\n(\n"),
826 "opening parenthesis must not start at column 1: {}",
827 output
828 );
829 }
830}