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