Skip to main content

runtime/tools/exec/
outcome.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The shape of every answer the exec tool gives: the four result
7//! payloads, and the two tails a caller reads after them.
8//!
9//! One file owns the key names — `arm`, `stdout`, `stderr`,
10//! `exit_code`, `outcome`, `handle`, `what`, `detail`, `background`,
11//! `env`, `interpreter` — so an arm cannot spell a result differently
12//! from its neighbour, and the tally that reads shell results back out
13//! of the ledger reads them under the same spellings
14//! (`crates/runtime/Spec.lean` §8-13-2 D30).
15
16use std::collections::BTreeMap;
17
18use kernel::{AxError, Payload, ToolOutcome};
19use serde_json::{Map, Value};
20
21use crate::backlog::{BacklogId, Exit, Finished};
22
23/// Writes how a child stopped into a result, in the one spelling this
24/// file owns.
25///
26/// A code is written only when there is a code: a command a signal
27/// stopped and a command this city never managed to wait on both used
28/// to be reported as `exit_code: -1`, which a model reads as a program
29/// that ran and failed.
30fn ending(result: &mut Map<String, Value>, exit: Exit) {
31    match exit {
32        Exit::Ended { code } => {
33            result.insert(
34                "exit_code".to_owned(),
35                Value::Number(i64::from(code).into()),
36            );
37        }
38        Exit::Signalled => {
39            result.insert(
40                "outcome".to_owned(),
41                Value::String(exit.as_str().to_owned()),
42            );
43            result.insert(
44                "detail".to_owned(),
45                Value::String(
46                    "a signal stopped this command, so it returned no code; `halt` is \
47                     what usually sends one"
48                        .to_owned(),
49                ),
50            );
51        }
52        Exit::Unknown { why } => {
53            result.insert(
54                "outcome".to_owned(),
55                Value::String(exit.as_str().to_owned()),
56            );
57            result.insert(
58                "detail".to_owned(),
59                Value::String(why.sentence().to_owned()),
60            );
61        }
62    }
63}
64
65/// What a caller is told about a command that outlived its window.
66///
67/// The handle and the sentence travel together: an agent that is given
68/// an identifier and no instruction waits for it anyway, which is the
69/// behaviour this whole table exists to stop.
70pub(super) fn backgrounded(id: &BacklogId, what: &str, arm: &str) -> Result<ToolOutcome, AxError> {
71    let mut result = Map::new();
72    result.insert("arm".to_owned(), Value::String(arm.to_owned()));
73    result.insert(
74        "outcome".to_owned(),
75        Value::String("backgrounded".to_owned()),
76    );
77    result.insert("handle".to_owned(), Value::String(id.to_string()));
78    result.insert("what".to_owned(), Value::String(what.to_owned()));
79    result.insert(
80        "detail".to_owned(),
81        Value::String(
82            "still running; do not wait for it - carry on, and its result arrives at the end \
83             of a later tool result"
84                .to_owned(),
85        ),
86    );
87    Ok(ToolOutcome {
88        result: Payload::new(result)?,
89        attachments: Vec::new(),
90    })
91}
92
93/// Adds every background member that has stopped since the last call to
94/// the tail of this result. A command that settles inside its window
95/// carries no handle: the caller asked whether it finished, the table
96/// answered, and the entry is already gone.
97///
98/// It is the tail rather than the head because the answer the caller
99/// asked for is the one it is reading for; what arrived while it was
100/// working comes after.
101pub(super) fn with_backlog(
102    outcome: ToolOutcome,
103    done: Vec<Finished>,
104) -> Result<ToolOutcome, AxError> {
105    if done.is_empty() {
106        return Ok(outcome);
107    }
108    let mut result = outcome.result.as_map().clone();
109    let rows = done
110        .into_iter()
111        .map(|member| {
112            let mut row = Map::new();
113            row.insert("handle".to_owned(), Value::String(member.id.to_string()));
114            row.insert("what".to_owned(), Value::String(member.what));
115            ending(&mut row, member.exit);
116            row.insert("stdout".to_owned(), Value::String(member.stdout));
117            row.insert("stderr".to_owned(), Value::String(member.stderr));
118            Value::Object(row)
119        })
120        .collect();
121    result.insert("background".to_owned(), Value::Array(rows));
122    Ok(ToolOutcome {
123        result: Payload::new(result)?,
124        attachments: Vec::new(),
125    })
126}
127
128pub(super) fn settled(
129    stdout: &str,
130    stderr: &str,
131    exit: Exit,
132    arm: &str,
133) -> Result<ToolOutcome, AxError> {
134    let mut result = Map::new();
135    result.insert("arm".to_owned(), Value::String(arm.to_owned()));
136    result.insert("stdout".to_owned(), Value::String(stdout.to_owned()));
137    result.insert("stderr".to_owned(), Value::String(stderr.to_owned()));
138    ending(&mut result, exit);
139    Ok(ToolOutcome {
140        result: Payload::new(result)?,
141        attachments: Vec::new(),
142    })
143}
144
145/// Adds the names this call's child inherited to its result.
146///
147/// Names only, never values: which names a run inherited is a fact the
148/// ledger keeps, and what those names held is a fact it must not.
149pub(super) fn with_environment(
150    outcome: ToolOutcome,
151    inherited: &BTreeMap<String, String>,
152) -> Result<ToolOutcome, AxError> {
153    let mut result = outcome.result.as_map().clone();
154    result.insert(
155        "env".to_owned(),
156        Value::Array(
157            inherited
158                .keys()
159                .map(|name| Value::String(name.clone()))
160                .collect(),
161        ),
162    );
163    Ok(ToolOutcome {
164        result: Payload::new(result)?,
165        attachments: Vec::new(),
166    })
167}
168
169/// Adds which interpreter ran a shell line to its result.
170pub(super) fn with_interpreter(
171    outcome: ToolOutcome,
172    interpreter: &str,
173) -> Result<ToolOutcome, AxError> {
174    let mut result = outcome.result.as_map().clone();
175    result.insert(
176        INTERPRETER.to_owned(),
177        Value::String(interpreter.to_owned()),
178    );
179    Ok(ToolOutcome {
180        result: Payload::new(result)?,
181        attachments: Vec::new(),
182    })
183}
184
185const INTERPRETER: &str = "interpreter";
186
187/// What a record written before results named their interpreter is
188/// counted under: then the shell arm only ever ran the platform's own.
189const BEFORE_INTERPRETERS: &str = "system";
190
191/// One class of shell failure a reader can act on.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
193pub enum FailureClass {
194    /// The interpreter found no command by the name the line gave.
195    CommandNotFound,
196    /// The interpreter could not parse the line.
197    Syntax,
198    /// The output held bytes that were not UTF-8, written down as U+FFFD.
199    Encoding,
200}
201
202/// The interpreter families whose failures read alike.
203enum Family {
204    Cmd,
205    Pwsh,
206    Posix,
207    /// A record from before results named their interpreter.
208    Unnamed,
209}
210
211impl Family {
212    fn of(interpreter: &str) -> Family {
213        match interpreter {
214            "cmd" => Family::Cmd,
215            "pwsh" | "powershell" => Family::Pwsh,
216            BEFORE_INTERPRETERS => Family::Unnamed,
217            _ => Family::Posix,
218        }
219    }
220}
221
222impl FailureClass {
223    /// The spelling a reader of the tally sees.
224    #[must_use]
225    pub fn as_str(self) -> &'static str {
226        match self {
227            FailureClass::CommandNotFound => "command_not_found",
228            FailureClass::Syntax => "syntax",
229            FailureClass::Encoding => "encoding",
230        }
231    }
232
233    /// Which class a shell result's failure falls in, or `None` for a
234    /// success and for a failure no rule recognises.
235    ///
236    /// The exit code is read first, then the error ids that do not change
237    /// with the display language, then the replacement character, and
238    /// English text last: cmd prints its syntax errors in the language
239    /// Windows is set to, so on another language they stay unclassified
240    /// rather than this file carrying a second copy of Microsoft's text.
241    #[must_use]
242    pub fn of(interpreter: &str, exit_code: i64, stdout: &str, stderr: &str) -> Option<Self> {
243        if exit_code == 0 {
244            return None;
245        }
246        let family = Family::of(interpreter);
247        let not_found = match family {
248            Family::Cmd => exit_code == 9009,
249            Family::Posix => exit_code == 127,
250            Family::Pwsh => stderr.contains("CommandNotFoundException"),
251            Family::Unnamed => exit_code == 9009 || exit_code == 127,
252        };
253        let parser = matches!(family, Family::Pwsh) && stderr.contains("ParserError");
254        let cmd_text = stderr.contains("was unexpected at this time.")
255            || stderr.contains("The syntax of the command is incorrect.");
256        let posix_text = exit_code == 2 && stderr.contains("syntax error");
257        let syntax_text = match family {
258            Family::Cmd => cmd_text,
259            Family::Posix => posix_text,
260            Family::Pwsh => false,
261            Family::Unnamed => cmd_text || posix_text,
262        };
263        if not_found {
264            Some(FailureClass::CommandNotFound)
265        } else if parser {
266            Some(FailureClass::Syntax)
267        } else if stdout.contains('\u{FFFD}') || stderr.contains('\u{FFFD}') {
268            Some(FailureClass::Encoding)
269        } else if syntax_text {
270            Some(FailureClass::Syntax)
271        } else {
272            None
273        }
274    }
275}
276
277/// How one interpreter's shell lines ended.
278#[derive(Debug, Clone, Default, PartialEq, Eq)]
279pub struct ShellCount {
280    /// Lines that ended with a code.
281    pub calls: u64,
282    /// Of those, how many failed in each class.
283    pub failures: BTreeMap<FailureClass, u64>,
284}
285
286/// Shell results folded per interpreter: the reading that decides
287/// whether PowerShell 7 should become the default (D30).
288#[derive(Debug, Clone, Default, PartialEq, Eq)]
289pub struct ShellTally {
290    by_interpreter: BTreeMap<String, ShellCount>,
291}
292
293impl ShellTally {
294    /// Counts one exec result, the `result` object of a tool result
295    /// record. Any other arm, and a shell line that ended without a code
296    /// (handed to the background, or stopped by a signal), is not
297    /// counted: there is nothing yet to classify.
298    pub fn absorb(&mut self, result: &Map<String, Value>) {
299        if result.get("arm").and_then(Value::as_str) != Some("shell") {
300            return;
301        }
302        let Some(code) = result.get("exit_code").and_then(Value::as_i64) else {
303            return;
304        };
305        let text = |key: &str| result.get(key).and_then(Value::as_str).unwrap_or("");
306        let interpreter = result
307            .get(INTERPRETER)
308            .and_then(Value::as_str)
309            .unwrap_or(BEFORE_INTERPRETERS);
310        let count = self
311            .by_interpreter
312            .entry(interpreter.to_owned())
313            .or_default();
314        count.calls = count.calls.saturating_add(1);
315        if let Some(class) = FailureClass::of(interpreter, code, text("stdout"), text("stderr")) {
316            let failed = count.failures.entry(class).or_default();
317            *failed = failed.saturating_add(1);
318        }
319    }
320
321    /// Every interpreter seen, by name, with its count.
322    pub fn interpreters(&self) -> impl Iterator<Item = (&str, &ShellCount)> {
323        self.by_interpreter
324            .iter()
325            .map(|(name, count)| (name.as_str(), count))
326    }
327}
328
329pub(super) fn exceptional(
330    stdout: &[u8],
331    stderr: &[u8],
332    kind: &str,
333    detail: Option<String>,
334) -> Result<ToolOutcome, AxError> {
335    let mut result = Map::new();
336    result.insert("arm".to_owned(), Value::String("python".to_owned()));
337    result.insert(
338        "stdout".to_owned(),
339        Value::String(String::from_utf8_lossy(stdout).into_owned()),
340    );
341    result.insert(
342        "stderr".to_owned(),
343        Value::String(String::from_utf8_lossy(stderr).into_owned()),
344    );
345    result.insert("outcome".to_owned(), Value::String(kind.to_owned()));
346    if let Some(detail) = detail {
347        result.insert("detail".to_owned(), Value::String(detail));
348    }
349    Ok(ToolOutcome {
350        result: Payload::new(result)?,
351        attachments: Vec::new(),
352    })
353}