Skip to main content

start_command/
status_formatter.rs

1//! Status formatter module for execution records
2//!
3//! Provides formatting functions for execution status output in various formats:
4//! - Links Notation (links-notation): Structured link doublet format with nested options
5//! - JSON: Standard JSON output
6//! - Text: Human-readable text format
7
8use 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/// Live state of a detached docker container by name.
17#[derive(Clone, Copy)]
18struct DockerState {
19    running: bool,
20    exit_code: Option<i32>,
21    oom_killed: Option<bool>,
22}
23
24/// Inspect the live state of a detached docker container by name.
25///
26/// Distinguishes "running", "stopped (with a real exit code)", and "cannot be
27/// inspected at all". The last case matters on slow Docker-in-Docker hosts
28/// (issue #136): right after `docker run -d` returns, `docker inspect <name>`
29/// can transiently fail because the container is not visible yet. A failed
30/// inspect must NOT be read as "stopped"; it means "unknown", so callers can
31/// keep the session running instead of fabricating a terminal `-1` result.
32///
33/// Returns None when the container cannot be inspected (not found yet, removed,
34/// or docker error).
35fn 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
86/// Best-effort terminal exit code reported by the isolation backend itself
87/// (currently docker via `docker inspect .State.ExitCode`). Returns None when
88/// the backend cannot provide a real code, so callers never surface the `-1`
89/// sentinel for a session whose real exit code is simply not available yet.
90fn 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
113/// Check if a detached isolation session is still running
114/// Returns Some(true) if running, Some(false) if not, None if unable to determine
115pub 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            // A failed inspect means the container is not visible yet (still
139            // being created on a slow DinD host) or already removed — not
140            // "stopped". Return None (unknown) so the session is not falsely
141            // marked finished (issue #136).
142            inspect_docker_state(session_name).map(|state| state.running)
143        }
144        "ssh" => {
145            // For SSH, check if the local wrapper PID is still running
146            #[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
174/// Enrich execution record with live session status for detached executions.
175/// If a record shows "executing" but the detached session has actually ended,
176/// returns an updated copy with status "executed". If it shows "executed" but
177/// the session is still running, returns a copy with status "executing".
178pub 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            // Liveness is unknown: the backend could not be probed (e.g. a
212            // detached docker container that is not visible yet on a slow
213            // Docker-in-Docker host, or one that has already been removed).
214            // Honor a terminal `Exit Code:` footer if the command wrote one;
215            // otherwise leave the record untouched (still executing) rather than
216            // fabricating a `-1` terminal result that orchestrators misread as a
217            // finished/failed run (issue #136).
218            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        // A live `screen -ls` (or `tmux`/`docker`) session does NOT mean the command
240        // is still running: a lingering shell can outlive a killed command (e.g. the
241        // OOM killer sends SIGKILL, exit 137, but the login shell stays up for a
242        // window after `start` already wrote the terminal footer). The footer/recorded
243        // exit code is authoritative. Only flip back to "executing" when there is NO
244        // recorded terminal exit code AND no `Exit Code:` footer in the log.
245        if enriched.exit_code.is_none() && footer_exit.is_none() {
246            // Session still running and no terminal record - correct it
247            enriched.status = ExecutionStatus::Executing;
248            enriched.exit_code = None;
249            enriched.end_time = None;
250        }
251        // Otherwise keep the recorded/footer exit code - the command has finished.
252    } else if !alive && enriched.status == ExecutionStatus::Executing {
253        // Session ended but record says executing - correct it. Resolve a real
254        // exit code: prefer the log footer, then the backend's own record (e.g.
255        // `docker inspect .State.ExitCode`), and only fall back to the `-1`
256        // sentinel as a last resort when no real code can be obtained (issue #136).
257        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
273/// Compute a `currentTime` value for a record if its status is `executing`.
274/// Returns `None` for completed records. Wrapping this in a helper makes it
275/// easy to attach the same timestamp to all output formats and to test the
276/// behavior deterministically.
277pub 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
285/// Format execution record as Links Notation (indented style)
286/// Uses nested Links notation for object values (like options) instead of JSON
287///
288/// Output format:
289/// ```text
290/// <uuid>
291///   <key> <value>
292///   options
293///     <nested_key> <nested_value>
294///   ...
295/// ```
296pub fn format_record_as_links_notation(record: &ExecutionRecord) -> String {
297    format_record_as_links_notation_with_current_time(record, None)
298}
299
300/// Same as [`format_record_as_links_notation`] but injects a `currentTime`
301/// field (right after `startTime`) when a value is supplied.
302pub 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                    // Format options as nested Links notation
377                    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                            // For other complex types, use nested format
396                            format_value_for_links_notation(&value)
397                        }
398                    };
399                    lines.push(format!("  {} {}", key, formatted_value));
400                }
401            }
402
403            // Insert processIds right after pid so status output groups process
404            // identity with the wrapper PID already present in older output.
405            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            // Insert currentTime right after startTime for readability
412            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
423/// Format execution record as human-readable text
424pub fn format_record_as_text(record: &ExecutionRecord) -> String {
425    format_record_as_text_with_current_time(record, None)
426}
427
428/// Same as [`format_record_as_text`] but adds a `Current Time:` line right
429/// after `Start Time:` when a value is supplied.
430pub 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    // Format options as nested list instead of JSON
501    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
535/// Format execution record based on format type
536pub fn format_record(record: &ExecutionRecord, format: &str) -> Result<String, String> {
537    format_record_with_current_time(record, format, None)
538}
539
540/// Same as [`format_record`] but the output includes `currentTime` when a
541/// value is supplied. Use this from [`query_status`] so all three formats
542/// stay in sync.
543pub 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
591/// Format execution records as a Links Notation list.
592pub 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, &current_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
630/// Format execution records as human-readable text.
631pub 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, &current_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
684/// Format execution records based on format type.
685pub 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, &current_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
718/// Query result from status lookup
719pub struct StatusQueryResult {
720    pub success: bool,
721    pub output: Option<String>,
722    pub error: Option<String>,
723}
724
725/// Handle execution list query and return the result
726pub 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, &current_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
762/// Handle status query and return the result
763pub 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    // Enrich detached execution status with live session check
794    let enriched = enrich_detached_status(&record);
795    // Attach currentTime so callers can see how long an executing command has been running
796    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}