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