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