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::{
11    describe_exit_code, resolve_exit_reason, resolve_memory_exhaustion, CGROUP_OOM_EXIT_REASON,
12};
13use crate::isolation::isolation_log::{read_log_tail, FATAL_MARKER_TAIL_BYTES};
14use crate::output_blocks::{escape_for_links_notation, format_value_for_links_notation};
15use crate::status_footer::read_footer_from_log;
16use crate::status_probe::{
17    apply_end_time, backend_exit_code, is_detached_docker_record, is_detached_session_alive,
18    read_docker_state, resolve_oom_observation,
19};
20use chrono::Utc;
21use serde_json::Value;
22
23/// Read the terminal exit code from the anchored footer at the end of a log.
24///
25/// Thin wrapper kept for compatibility: the footer itself is parsed by
26/// `status_footer`, which also returns the `Finished:` timestamp callers need
27/// for `end_time` (issue #170.2).
28pub fn read_exit_code_from_log(log_path: &str) -> Option<i32> {
29    read_footer_from_log(log_path).exit_code
30}
31
32/// Enrich execution record with live session status and an exit reason hint.
33///
34/// The hint explains an otherwise opaque exit code (`139` with `oomKilled
35/// false` is the motivating case from issue #162) and is derived from the same
36/// log tail the footer scan already reads. It is purely additive: `status`,
37/// `exitCode` and `oomKilled` are never changed by it. The same tail also
38/// yields the `memoryExhausted` observation for consumers that key off
39/// `oomKilled` (issue #165).
40pub fn enrich_detached_status(record: &ExecutionRecord) -> ExecutionRecord {
41    let mut enriched = resolve_detached_status(record);
42    if enriched.status != ExecutionStatus::Executed {
43        return enriched;
44    }
45    let tail = read_log_tail(&enriched.log_path, FATAL_MARKER_TAIL_BYTES);
46    let oom_kills = enriched.cgroup_memory.and_then(|memory| memory.oom_kills);
47    // A record finalized before #180 may carry the cgroup OOM reason for an
48    // ordinary exit (the sticky flag was blamed for exit 0/1): re-derive it.
49    if enriched.exit_reason.is_none()
50        || enriched.exit_reason.as_deref() == Some(CGROUP_OOM_EXIT_REASON)
51    {
52        enriched.exit_reason = resolve_exit_reason(
53            enriched.exit_code,
54            tail.as_deref(),
55            enriched.oom_killed,
56            oom_kills,
57        );
58    }
59    if enriched.memory_exhausted.is_none() {
60        if let Some(memory) = resolve_memory_exhaustion(
61            enriched.exit_code,
62            tail.as_deref(),
63            enriched.oom_killed,
64            oom_kills,
65        ) {
66            enriched.memory_exhausted = Some(memory.memory_exhausted);
67            enriched.memory_exhausted_reason = Some(memory.memory_exhausted_reason);
68        }
69    }
70    enriched
71}
72
73/// Reconcile the stored status of a detached execution with its live session.
74/// If a record shows "executing" but the detached session has actually ended,
75/// returns an updated copy with status "executed". If it shows "executed" but
76/// the session is still running, returns a copy with status "executing".
77fn resolve_detached_status(record: &ExecutionRecord) -> ExecutionRecord {
78    let footer = read_footer_from_log(&record.log_path);
79    let footer_exit = footer.exit_code;
80    let is_detached_docker = is_detached_docker_record(record);
81    let docker_state = if is_detached_docker {
82        read_docker_state(record)
83    } else {
84        None
85    };
86
87    // `oomKilled` is exposed alongside the status, but never decides it (#151).
88    let oom_killed = resolve_oom_observation(record, docker_state.as_ref());
89    let clone_record = || {
90        let mut enriched = record.clone();
91        if oom_killed.is_some() {
92            enriched.oom_killed = oom_killed;
93        }
94        enriched
95    };
96
97    let alive = if is_detached_docker {
98        docker_state.as_ref().map(|state| state.running)
99    } else {
100        is_detached_session_alive(record)
101    };
102
103    let alive = match alive {
104        Some(value) => value,
105        None => {
106            // Liveness is unknown: the backend could not be probed (e.g. a
107            // detached docker container that is not visible yet on a slow
108            // Docker-in-Docker host, or one that has already been removed).
109            // Honor a terminal `Exit Code:` footer if the command wrote one;
110            // otherwise leave the record untouched (still executing) rather than
111            // fabricating a `-1` terminal result that orchestrators misread as a
112            // finished/failed run (issue #136).
113            let is_detached =
114                record.options.get("isolationMode").and_then(|v| v.as_str()) == Some("detached");
115            if is_detached && record.status == ExecutionStatus::Executing {
116                if footer_exit.is_some() {
117                    let mut enriched = clone_record();
118                    enriched.status = ExecutionStatus::Executed;
119                    enriched.exit_code = footer_exit;
120                    apply_end_time(&mut enriched, docker_state.as_ref(), &footer);
121                    return enriched;
122                }
123                if oom_killed == Some(true) {
124                    // The container is gone and wrote no footer, so the OOM
125                    // observation is the only evidence left: report the session
126                    // as finished with the conventional SIGKILL code (issue
127                    // #148). While the container is still inspectable this
128                    // branch is never taken — a live container keeps the session
129                    // `executing` no matter what the cgroup flag says (#151).
130                    let mut enriched = clone_record();
131                    enriched.status = ExecutionStatus::Executed;
132                    enriched.exit_code = Some(137);
133                    apply_end_time(&mut enriched, docker_state.as_ref(), &footer);
134                    return enriched;
135                }
136            }
137            return clone_record();
138        }
139    };
140
141    let mut enriched = clone_record();
142
143    if alive && enriched.status == ExecutionStatus::Executed {
144        // A live `screen -ls` (or `tmux`/`docker`) session does NOT mean the command
145        // is still running: a lingering shell can outlive a killed command (e.g. the
146        // OOM killer sends SIGKILL, exit 137, but the login shell stays up for a
147        // window after `start` already wrote the terminal footer). The footer/recorded
148        // exit code is authoritative. Only flip back to "executing" when there is NO
149        // recorded terminal exit code AND no `Exit Code:` footer in the log.
150        if enriched.exit_code.is_none() && footer_exit.is_none() {
151            // Session still running and no terminal record - correct it
152            enriched.status = ExecutionStatus::Executing;
153            enriched.exit_code = None;
154            enriched.end_time = None;
155            enriched.end_time_source = None;
156        }
157        // Otherwise keep the recorded/footer exit code - the command has finished.
158    } else if !alive && enriched.status == ExecutionStatus::Executing {
159        // Session ended but record says executing - correct it. Resolve a real
160        // exit code: prefer the backend's own record (e.g. `docker inspect
161        // .State.ExitCode`, which is authoritative and cannot be spoofed by
162        // command output — issue #150), then the anchored log footer, then
163        // `137` when the only evidence left is the OOM observation, and only
164        // fall back to the `-1` sentinel as a last resort when no real code can
165        // be obtained (issues #136, #151).
166        enriched.status = ExecutionStatus::Executed;
167        if enriched.exit_code.is_none() {
168            enriched.exit_code = Some(
169                backend_exit_code(docker_state.as_ref())
170                    .or(footer_exit)
171                    .or(if oom_killed == Some(true) {
172                        Some(137)
173                    } else {
174                        None
175                    })
176                    .unwrap_or(-1),
177            );
178        }
179        apply_end_time(&mut enriched, docker_state.as_ref(), &footer);
180    }
181
182    enriched
183}
184
185/// Compute a `currentTime` value for a record if its status is `executing`.
186/// Returns `None` for completed records. Wrapping this in a helper makes it
187/// easy to attach the same timestamp to all output formats and to test the
188/// behavior deterministically.
189pub fn attach_current_time(record: &ExecutionRecord) -> Option<String> {
190    if record.status == ExecutionStatus::Executing {
191        Some(Utc::now().to_rfc3339())
192    } else {
193        None
194    }
195}
196
197/// Format execution record as Links Notation (indented style)
198/// Uses nested Links notation for object values (like options) instead of JSON
199///
200/// Output format:
201/// ```text
202/// <uuid>
203///   <key> <value>
204///   options
205///     <nested_key> <nested_value>
206///   ...
207/// ```
208pub fn format_record_as_links_notation(record: &ExecutionRecord) -> String {
209    format_record_as_links_notation_with_current_time(record, None)
210}
211
212/// Same as [`format_record_as_links_notation`] but injects a `currentTime`
213/// field (right after `startTime`) when a value is supplied.
214pub fn format_record_as_links_notation_with_current_time(
215    record: &ExecutionRecord,
216    current_time: Option<&str>,
217) -> String {
218    format_record_as_links_notation_with_enrichments(record, current_time, None)
219}
220
221fn append_links_array(lines: &mut Vec<String>, values: &[Value], indent: usize) {
222    let prefix = " ".repeat(indent);
223    if values.is_empty() {
224        lines.push(format!("{}()", prefix));
225        return;
226    }
227
228    lines.push(format!("{}(", prefix));
229    for value in values {
230        match value {
231            Value::Array(nested) => append_links_array(lines, nested, indent + 2),
232            Value::Object(map) => {
233                for (child_key, child_value) in map {
234                    if !child_value.is_null() {
235                        append_links_value(lines, child_key, child_value, indent + 2);
236                    }
237                }
238            }
239            _ => lines.push(format!(
240                "{}{}",
241                " ".repeat(indent + 2),
242                format_value_for_links_notation(value)
243            )),
244        }
245    }
246    lines.push(format!("{})", prefix));
247}
248
249fn append_links_value(lines: &mut Vec<String>, key: &str, value: &Value, indent: usize) {
250    let prefix = " ".repeat(indent);
251    match value {
252        Value::Object(map) => {
253            if map.is_empty() {
254                return;
255            }
256            lines.push(format!("{}{}", prefix, key));
257            for (child_key, child_value) in map {
258                if !child_value.is_null() {
259                    append_links_value(lines, child_key, child_value, indent + 4);
260                }
261            }
262        }
263        Value::Array(values) => {
264            lines.push(format!("{}{}", prefix, key));
265            append_links_array(lines, values, indent + 2);
266        }
267        _ => lines.push(format!(
268            "{}{} {}",
269            prefix,
270            key,
271            format_value_for_links_notation(value)
272        )),
273    }
274}
275
276fn format_record_as_links_notation_with_enrichments(
277    record: &ExecutionRecord,
278    current_time: Option<&str>,
279    process_ids: Option<&Value>,
280) -> String {
281    let json = record.to_json();
282    let mut lines = vec![record.uuid.clone()];
283
284    if let Value::Object(map) = json {
285        for (key, value) in map {
286            if !value.is_null() {
287                if key == "options" {
288                    // Format options as nested Links notation
289                    if let Value::Object(opts) = &value {
290                        if !opts.is_empty() {
291                            lines.push("  options".to_string());
292                            for (opt_key, opt_value) in opts {
293                                if !opt_value.is_null() {
294                                    let formatted = format_value_for_links_notation(opt_value);
295                                    lines.push(format!("    {} {}", opt_key, formatted));
296                                }
297                            }
298                        }
299                    }
300                } else if value.is_object() {
301                    // Nested records such as `cgroupMemory` (issue #182), in
302                    // the same layout as the JavaScript formatter.
303                    append_links_value(&mut lines, &key, &value, 2);
304                } else {
305                    let formatted_value = match &value {
306                        Value::String(s) => escape_for_links_notation(s),
307                        Value::Bool(b) => b.to_string(),
308                        Value::Number(n) => n.to_string(),
309                        Value::Null => "null".to_string(),
310                        Value::Object(_) | Value::Array(_) => {
311                            // For other complex types, use nested format
312                            format_value_for_links_notation(&value)
313                        }
314                    };
315                    lines.push(format!("  {} {}", key, formatted_value));
316                }
317            }
318
319            // Insert processIds right after pid so status output groups process
320            // identity with the wrapper PID already present in older output.
321            if key == "pid" {
322                if let Some(process_ids) = process_ids {
323                    append_links_value(&mut lines, "processIds", process_ids, 2);
324                }
325            }
326
327            // Insert currentTime right after startTime for readability
328            if key == "startTime" {
329                if let Some(ct) = current_time {
330                    lines.push(format!("  currentTime {}", escape_for_links_notation(ct)));
331                }
332            }
333        }
334    }
335
336    lines.join("\n")
337}
338
339/// Format execution record as human-readable text
340pub fn format_record_as_text(record: &ExecutionRecord) -> String {
341    format_record_as_text_with_current_time(record, None)
342}
343
344/// Same as [`format_record_as_text`] but adds a `Current Time:` line right
345/// after `Start Time:` when a value is supplied.
346pub fn format_record_as_text_with_current_time(
347    record: &ExecutionRecord,
348    current_time: Option<&str>,
349) -> String {
350    format_record_as_text_with_enrichments(record, current_time, None)
351}
352
353fn append_text_process_ids(lines: &mut Vec<String>, process_ids: &Value) {
354    let Value::Object(map) = process_ids else {
355        return;
356    };
357    if map.is_empty() {
358        return;
359    }
360
361    lines.push("Process IDs:".to_string());
362    for (key, value) in map {
363        let value_str = match value {
364            Value::String(s) => s.clone(),
365            Value::Bool(b) => b.to_string(),
366            Value::Number(n) => n.to_string(),
367            Value::Null => "null".to_string(),
368            other => serde_json::to_string(other).unwrap_or_default(),
369        };
370        lines.push(format!("  {}: {}", key, value_str));
371    }
372}
373
374fn format_record_as_text_with_enrichments(
375    record: &ExecutionRecord,
376    current_time: Option<&str>,
377    process_ids: Option<&Value>,
378) -> String {
379    let exit_code_str = record
380        .exit_code
381        // Decoded through the same helper the completion watcher generates its
382        // `case` table from, so `137` reads as `137 (SIGKILL - 128+9)` here and
383        // in the log post-mortem alike (issue #171.4).
384        .map(|c| describe_exit_code(Some(c)).text)
385        .unwrap_or_else(|| "N/A".to_string());
386    let pid_str = record
387        .pid
388        .map(|p| p.to_string())
389        .unwrap_or_else(|| "N/A".to_string());
390    let end_time_str = record.end_time.as_deref().unwrap_or("N/A");
391
392    let mut lines = vec![
393        "Execution Status".to_string(),
394        "=".repeat(50),
395        format!("UUID:              {}", record.uuid),
396        format!("Status:            {}", record.status),
397        format!("Command:           {}", record.command),
398        format!("Exit Code:         {}", exit_code_str),
399    ];
400    if let Some(oom_killed) = record.oom_killed {
401        lines.push(format!("OOM Killed:        {}", oom_killed));
402    }
403    if let Some(ref exit_reason) = record.exit_reason {
404        lines.push(format!("Exit Reason:       {}", exit_reason));
405    }
406    if let Some(memory_exhausted) = record.memory_exhausted {
407        lines.push(format!("Memory Exhausted:  {}", memory_exhausted));
408    }
409    if let Some(ref reason) = record.memory_exhausted_reason {
410        lines.push(format!("Memory Evidence:   {}", reason));
411    }
412    // cgroup v2 counters the detached watcher sampled (issue #182).
413    if let Some(ref memory) = record.cgroup_memory {
414        lines.push(format!(
415            "Cgroup Memory:     {}",
416            crate::cgroup_memory::format_cgroup_memory(memory)
417        ));
418    }
419    lines.push(format!("PID:               {}", pid_str));
420    if let Some(process_ids) = process_ids {
421        append_text_process_ids(&mut lines, process_ids);
422    }
423    lines.extend([
424        format!("Working Directory: {}", record.working_directory),
425        format!("Shell:             {}", record.shell),
426        format!("Platform:          {}", record.platform),
427        format!("Start Time:        {}", record.start_time),
428    ]);
429    if let Some(ct) = current_time {
430        lines.push(format!("Current Time:      {}", ct));
431    }
432    lines.push(format!("End Time:          {}", end_time_str));
433    if let Some(ref source) = record.end_time_source {
434        // Where `End Time` came from: a real finish time, or merely when
435        // `start` noticed the execution was over (issue #170.2).
436        lines.push(format!("End Time Source:   {}", source));
437    }
438    if let Some(ref started) = record.container_started_at {
439        lines.push(format!("Container Started: {}", started));
440    }
441    if let Some(ref detected) = record.stale_detected_at {
442        lines.push(format!("Stale Detected At: {}", detected));
443    }
444    lines.push(format!("Log Path:          {}", record.log_path));
445
446    // Format options as nested list instead of JSON
447    if !record.options.is_empty() {
448        lines.push("Options:".to_string());
449        for (key, value) in &record.options {
450            let value_str = match value {
451                Value::String(s) => s.clone(),
452                Value::Bool(b) => b.to_string(),
453                Value::Number(n) => n.to_string(),
454                Value::Null => "null".to_string(),
455                other => serde_json::to_string(other).unwrap_or_default(),
456            };
457            lines.push(format!("  {}: {}", key, value_str));
458        }
459    }
460
461    lines.join("\n")
462}
463
464fn record_json_with_enrichments(
465    record: &ExecutionRecord,
466    current_time: Option<&str>,
467    process_ids: Option<&Value>,
468) -> Value {
469    let mut json = record.to_json();
470    if let Value::Object(map) = &mut json {
471        if let Some(process_ids) = process_ids {
472            map.insert("processIds".to_string(), process_ids.clone());
473        }
474        if let Some(ct) = current_time {
475            map.insert("currentTime".to_string(), Value::String(ct.to_string()));
476        }
477    }
478    json
479}
480
481/// Format execution record based on format type
482pub fn format_record(record: &ExecutionRecord, format: &str) -> Result<String, String> {
483    format_record_with_current_time(record, format, None)
484}
485
486/// Same as [`format_record`] but the output includes `currentTime` when a
487/// value is supplied. Use this from [`query_status`] so all three formats
488/// stay in sync.
489pub fn format_record_with_current_time(
490    record: &ExecutionRecord,
491    format: &str,
492    current_time: Option<&str>,
493) -> Result<String, String> {
494    format_record_with_enrichments(record, format, current_time, None)
495}
496
497fn format_record_with_enrichments(
498    record: &ExecutionRecord,
499    format: &str,
500    current_time: Option<&str>,
501    process_ids: Option<&Value>,
502) -> Result<String, String> {
503    match format {
504        "links-notation" => Ok(format_record_as_links_notation_with_enrichments(
505            record,
506            current_time,
507            process_ids,
508        )),
509        "json" => serde_json::to_string_pretty(&record_json_with_enrichments(
510            record,
511            current_time,
512            process_ids,
513        ))
514        .map_err(|e| format!("Failed to serialize to JSON: {}", e)),
515        "text" => Ok(format_record_as_text_with_enrichments(
516            record,
517            current_time,
518            process_ids,
519        )),
520        _ => Err(format!("Unknown output format: {}", format)),
521    }
522}
523
524fn sort_records_by_start_time_desc(records: &mut [ExecutionRecord]) {
525    records.sort_by(|a, b| b.start_time.cmp(&a.start_time));
526}
527
528fn indent_block(block: &str, spaces: usize) -> String {
529    let prefix = " ".repeat(spaces);
530    block
531        .lines()
532        .map(|line| format!("{}{}", prefix, line))
533        .collect::<Vec<_>>()
534        .join("\n")
535}
536
537/// Format execution records as a Links Notation list.
538pub fn format_record_list_as_links_notation(records: &[ExecutionRecord]) -> String {
539    let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
540    let process_ids = vec![None; records.len()];
541    format_record_list_as_links_notation_with_current_times(records, &current_times, &process_ids)
542}
543
544fn format_record_list_as_links_notation_with_current_times(
545    records: &[ExecutionRecord],
546    current_times: &[Option<String>],
547    process_ids: &[Option<Value>],
548) -> String {
549    let mut lines = vec![
550        "executions".to_string(),
551        format!("  count {}", records.len()),
552    ];
553
554    if records.is_empty() {
555        lines.push("  records ()".to_string());
556        return lines.join("\n");
557    }
558
559    lines.push("  records".to_string());
560    for ((record, current_time), process_ids) in records
561        .iter()
562        .zip(current_times.iter())
563        .zip(process_ids.iter())
564    {
565        let block = format_record_as_links_notation_with_enrichments(
566            record,
567            current_time.as_deref(),
568            process_ids.as_ref(),
569        );
570        lines.push(indent_block(&block, 4));
571    }
572
573    lines.join("\n")
574}
575
576/// Format execution records as human-readable text.
577pub fn format_record_list_as_text(records: &[ExecutionRecord]) -> String {
578    let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
579    let process_ids = vec![None; records.len()];
580    format_record_list_as_text_with_current_times(records, &current_times, &process_ids)
581}
582
583fn format_record_list_as_text_with_current_times(
584    records: &[ExecutionRecord],
585    current_times: &[Option<String>],
586    process_ids: &[Option<Value>],
587) -> String {
588    let mut lines = vec![
589        "Executions".to_string(),
590        "=".repeat(50),
591        format!("Count: {}", records.len()),
592    ];
593
594    for ((record, current_time), process_ids) in records
595        .iter()
596        .zip(current_times.iter())
597        .zip(process_ids.iter())
598    {
599        lines.push(String::new());
600        lines.push(format_record_as_text_with_enrichments(
601            record,
602            current_time.as_deref(),
603            process_ids.as_ref(),
604        ));
605    }
606
607    lines.join("\n")
608}
609
610fn record_list_json_with_current_times(
611    records: &[ExecutionRecord],
612    current_times: &[Option<String>],
613    process_ids: &[Option<Value>],
614) -> Value {
615    let executions: Vec<Value> = records
616        .iter()
617        .zip(current_times.iter())
618        .zip(process_ids.iter())
619        .map(|((record, current_time), process_ids)| {
620            record_json_with_enrichments(record, current_time.as_deref(), process_ids.as_ref())
621        })
622        .collect();
623
624    serde_json::json!({
625        "count": records.len(),
626        "executions": executions,
627    })
628}
629
630/// Format execution records based on format type.
631pub fn format_record_list(records: &[ExecutionRecord], format: &str) -> Result<String, String> {
632    let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
633    let process_ids = vec![None; records.len()];
634    format_record_list_with_current_times(records, format, &current_times, &process_ids)
635}
636
637fn format_record_list_with_current_times(
638    records: &[ExecutionRecord],
639    format: &str,
640    current_times: &[Option<String>],
641    process_ids: &[Option<Value>],
642) -> Result<String, String> {
643    match format {
644        "links-notation" => Ok(format_record_list_as_links_notation_with_current_times(
645            records,
646            current_times,
647            process_ids,
648        )),
649        "json" => serde_json::to_string_pretty(&record_list_json_with_current_times(
650            records,
651            current_times,
652            process_ids,
653        ))
654        .map_err(|e| format!("Failed to serialize to JSON: {}", e)),
655        "text" => Ok(format_record_list_as_text_with_current_times(
656            records,
657            current_times,
658            process_ids,
659        )),
660        _ => Err(format!("Unknown output format: {}", format)),
661    }
662}
663
664/// Query result from status lookup
665pub struct StatusQueryResult {
666    pub success: bool,
667    pub output: Option<String>,
668    pub error: Option<String>,
669}
670
671/// Handle execution list query and return the result
672pub fn list_executions(
673    store: Option<&ExecutionStore>,
674    output_format: Option<&str>,
675) -> StatusQueryResult {
676    list_executions_filtered(store, output_format, false)
677}
678
679/// Handle execution list query, optionally keeping only running executions.
680///
681/// `--list --running` reports the reconciled status, not the stored one: a
682/// record whose session already died is dropped even if the store still says
683/// "executing" (issue #162).
684pub fn list_executions_filtered(
685    store: Option<&ExecutionStore>,
686    output_format: Option<&str>,
687    running_only: bool,
688) -> StatusQueryResult {
689    let store = match store {
690        Some(s) => s,
691        None => {
692            return StatusQueryResult {
693                success: false,
694                output: None,
695                error: Some("Execution tracking is disabled.".to_string()),
696            }
697        }
698    };
699
700    let mut records: Vec<ExecutionRecord> =
701        store.get_all().iter().map(enrich_detached_status).collect();
702    if running_only {
703        records.retain(|record| record.status == ExecutionStatus::Executing);
704    }
705    sort_records_by_start_time_desc(&mut records);
706    let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
707    let process_ids: Vec<Option<Value>> = records.iter().map(collect_process_ids).collect();
708    let format = output_format.unwrap_or("links-notation");
709
710    match format_record_list_with_current_times(&records, format, &current_times, &process_ids) {
711        Ok(output) => StatusQueryResult {
712            success: true,
713            output: Some(output),
714            error: None,
715        },
716        Err(e) => StatusQueryResult {
717            success: false,
718            output: None,
719            error: Some(e),
720        },
721    }
722}
723
724/// Handle status query and return the result
725pub fn query_status(
726    store: Option<&ExecutionStore>,
727    identifier: &str,
728    output_format: Option<&str>,
729) -> StatusQueryResult {
730    let store = match store {
731        Some(s) => s,
732        None => {
733            return StatusQueryResult {
734                success: false,
735                output: None,
736                error: Some("Execution tracking is disabled.".to_string()),
737            }
738        }
739    };
740
741    let record = match store.get(identifier) {
742        Some(r) => r,
743        None => {
744            return StatusQueryResult {
745                success: false,
746                output: None,
747                error: Some(format!(
748                    "No execution found with UUID or session name: {}",
749                    identifier
750                )),
751            }
752        }
753    };
754
755    // Enrich detached execution status with live session check
756    let enriched = enrich_detached_status(&record);
757    // Attach currentTime so callers can see how long an executing command has been running
758    let current_time = attach_current_time(&enriched);
759    let process_ids = collect_process_ids(&enriched);
760
761    let format = output_format.unwrap_or("links-notation");
762    match format_record_with_enrichments(
763        &enriched,
764        format,
765        current_time.as_deref(),
766        process_ids.as_ref(),
767    ) {
768        Ok(output) => StatusQueryResult {
769            success: true,
770            output: Some(output),
771            error: None,
772        },
773        Err(e) => StatusQueryResult {
774            success: false,
775            output: None,
776            error: Some(e),
777        },
778    }
779}
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784    use crate::execution_store::ExecutionRecordOptions;
785    use serde_json::json;
786
787    fn executing_record() -> ExecutionRecord {
788        ExecutionRecord::with_options(ExecutionRecordOptions {
789            command: "sleep 60".to_string(),
790            uuid: Some("issue-126-rust".to_string()),
791            pid: Some(667105),
792            status: Some(ExecutionStatus::Executing),
793            log_path: Some("/tmp/issue-126.log".to_string()),
794            start_time: Some("2026-04-23T10:00:00Z".to_string()),
795            working_directory: Some("/home/user".to_string()),
796            shell: Some("/bin/bash".to_string()),
797            platform: Some("linux".to_string()),
798            ..Default::default()
799        })
800    }
801
802    #[test]
803    fn links_notation_indents_nested_process_id_arrays() {
804        let process_ids = json!({
805            "wrapperPid": 667105,
806            "screenPid": 667120,
807            "commandPids": [667121, 667122],
808        });
809        let output = format_record_with_enrichments(
810            &executing_record(),
811            "links-notation",
812            Some("2026-04-23T10:10:13.042Z"),
813            Some(&process_ids),
814        )
815        .expect("links-notation should format");
816
817        assert!(
818            output.contains(
819                "      commandPids\n        (\n          667121\n          667122\n        )"
820            ),
821            "processIds should be a nested indented block, output: {}",
822            output
823        );
824        assert!(
825            !output.contains("\n(\n"),
826            "opening parenthesis must not start at column 1: {}",
827            output
828        );
829    }
830}