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::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
21/// Read the terminal exit code from the anchored footer at the end of a log.
22///
23/// Thin wrapper kept for compatibility: the footer itself is parsed by
24/// `status_footer`, which also returns the `Finished:` timestamp callers need
25/// for `end_time` (issue #170.2).
26pub fn read_exit_code_from_log(log_path: &str) -> Option<i32> {
27    read_footer_from_log(log_path).exit_code
28}
29
30/// Enrich execution record with live session status and an exit reason hint.
31///
32/// The hint explains an otherwise opaque exit code (`139` with `oomKilled
33/// false` is the motivating case from issue #162) and is derived from the same
34/// log tail the footer scan already reads. It is purely additive: `status`,
35/// `exitCode` and `oomKilled` are never changed by it. The same tail also
36/// yields the `memoryExhausted` observation for consumers that key off
37/// `oomKilled` (issue #165).
38pub 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
59/// Reconcile the stored status of a detached execution with its live session.
60/// If a record shows "executing" but the detached session has actually ended,
61/// returns an updated copy with status "executed". If it shows "executed" but
62/// the session is still running, returns a copy with status "executing".
63fn 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    // `oomKilled` is exposed alongside the status, but never decides it (#151).
74    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            // Liveness is unknown: the backend could not be probed (e.g. a
93            // detached docker container that is not visible yet on a slow
94            // Docker-in-Docker host, or one that has already been removed).
95            // Honor a terminal `Exit Code:` footer if the command wrote one;
96            // otherwise leave the record untouched (still executing) rather than
97            // fabricating a `-1` terminal result that orchestrators misread as a
98            // finished/failed run (issue #136).
99            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                    // The container is gone and wrote no footer, so the OOM
111                    // observation is the only evidence left: report the session
112                    // as finished with the conventional SIGKILL code (issue
113                    // #148). While the container is still inspectable this
114                    // branch is never taken — a live container keeps the session
115                    // `executing` no matter what the cgroup flag says (#151).
116                    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        // A live `screen -ls` (or `tmux`/`docker`) session does NOT mean the command
131        // is still running: a lingering shell can outlive a killed command (e.g. the
132        // OOM killer sends SIGKILL, exit 137, but the login shell stays up for a
133        // window after `start` already wrote the terminal footer). The footer/recorded
134        // exit code is authoritative. Only flip back to "executing" when there is NO
135        // recorded terminal exit code AND no `Exit Code:` footer in the log.
136        if enriched.exit_code.is_none() && footer_exit.is_none() {
137            // Session still running and no terminal record - correct it
138            enriched.status = ExecutionStatus::Executing;
139            enriched.exit_code = None;
140            enriched.end_time = None;
141            enriched.end_time_source = None;
142        }
143        // Otherwise keep the recorded/footer exit code - the command has finished.
144    } else if !alive && enriched.status == ExecutionStatus::Executing {
145        // Session ended but record says executing - correct it. Resolve a real
146        // exit code: prefer the backend's own record (e.g. `docker inspect
147        // .State.ExitCode`, which is authoritative and cannot be spoofed by
148        // command output — issue #150), then the anchored log footer, then
149        // `137` when the only evidence left is the OOM observation, and only
150        // fall back to the `-1` sentinel as a last resort when no real code can
151        // be obtained (issues #136, #151).
152        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
171/// Compute a `currentTime` value for a record if its status is `executing`.
172/// Returns `None` for completed records. Wrapping this in a helper makes it
173/// easy to attach the same timestamp to all output formats and to test the
174/// behavior deterministically.
175pub 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
183/// Format execution record as Links Notation (indented style)
184/// Uses nested Links notation for object values (like options) instead of JSON
185///
186/// Output format:
187/// ```text
188/// <uuid>
189///   <key> <value>
190///   options
191///     <nested_key> <nested_value>
192///   ...
193/// ```
194pub fn format_record_as_links_notation(record: &ExecutionRecord) -> String {
195    format_record_as_links_notation_with_current_time(record, None)
196}
197
198/// Same as [`format_record_as_links_notation`] but injects a `currentTime`
199/// field (right after `startTime`) when a value is supplied.
200pub 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                    // Format options as nested Links notation
275                    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                            // For other complex types, use nested format
294                            format_value_for_links_notation(&value)
295                        }
296                    };
297                    lines.push(format!("  {} {}", key, formatted_value));
298                }
299            }
300
301            // Insert processIds right after pid so status output groups process
302            // identity with the wrapper PID already present in older output.
303            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            // Insert currentTime right after startTime for readability
310            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
321/// Format execution record as human-readable text
322pub fn format_record_as_text(record: &ExecutionRecord) -> String {
323    format_record_as_text_with_current_time(record, None)
324}
325
326/// Same as [`format_record_as_text`] but adds a `Current Time:` line right
327/// after `Start Time:` when a value is supplied.
328pub 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        // Decoded through the same helper the completion watcher generates its
364        // `case` table from, so `137` reads as `137 (SIGKILL - 128+9)` here and
365        // in the log post-mortem alike (issue #171.4).
366        .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        // Where `End Time` came from: a real finish time, or merely when
410        // `start` noticed the execution was over (issue #170.2).
411        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    // Format options as nested list instead of JSON
422    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
456/// Format execution record based on format type
457pub fn format_record(record: &ExecutionRecord, format: &str) -> Result<String, String> {
458    format_record_with_current_time(record, format, None)
459}
460
461/// Same as [`format_record`] but the output includes `currentTime` when a
462/// value is supplied. Use this from [`query_status`] so all three formats
463/// stay in sync.
464pub 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
512/// Format execution records as a Links Notation list.
513pub 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, &current_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
551/// Format execution records as human-readable text.
552pub 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, &current_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
605/// Format execution records based on format type.
606pub 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, &current_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
639/// Query result from status lookup
640pub struct StatusQueryResult {
641    pub success: bool,
642    pub output: Option<String>,
643    pub error: Option<String>,
644}
645
646/// Handle execution list query and return the result
647pub fn list_executions(
648    store: Option<&ExecutionStore>,
649    output_format: Option<&str>,
650) -> StatusQueryResult {
651    list_executions_filtered(store, output_format, false)
652}
653
654/// Handle execution list query, optionally keeping only running executions.
655///
656/// `--list --running` reports the reconciled status, not the stored one: a
657/// record whose session already died is dropped even if the store still says
658/// "executing" (issue #162).
659pub 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, &current_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
699/// Handle status query and return the result
700pub 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    // Enrich detached execution status with live session check
731    let enriched = enrich_detached_status(&record);
732    // Attach currentTime so callers can see how long an executing command has been running
733    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}