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