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