Skip to main content

artifactize_core/store/
result.rs

1//! Typed review results with the unchanged, untagged saved JSON envelope.
2
3use std::time::Duration;
4
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6use serde_json::{Map, Value};
7
8use crate::{agent::verdict::ValidatedResult, runtime::Verdict};
9
10/// Agent and Human results retain opaque owner fields. Runtime metadata is
11/// parsed once when a saved record enters the core, including reduced remote records.
12#[derive(Debug, Clone, PartialEq)]
13pub enum ExecutionResult {
14    Owner(ValidatedResult),
15    Runtime(RuntimeResult),
16}
17
18/// Missing and null are distinct in existing reduced/older records. Preserve both.
19#[derive(Debug, Clone, PartialEq, Default)]
20pub enum Field<T> {
21    #[default]
22    Missing,
23    Null,
24    Value(T),
25}
26
27impl<T> Field<T> {
28    pub fn value(&self) -> Option<&T> {
29        match self {
30            Self::Value(value) => Some(value),
31            Self::Missing | Self::Null => None,
32        }
33    }
34}
35
36#[derive(Debug, Clone, PartialEq)]
37pub struct RuntimeResult {
38    pub verdict: Verdict,
39    pub exit_code: Field<i32>,
40    pub duration: Field<Duration>,
41    pub truncated: Field<bool>,
42    pub stdout: Field<String>,
43    pub stderr: Field<String>,
44    /// Unknown fields from newer producers survive round trips unchanged.
45    pub fields: Map<String, Value>,
46}
47
48impl RuntimeResult {
49    pub fn summary(&self) -> Self {
50        Self {
51            stdout: Field::Missing,
52            stderr: Field::Missing,
53            fields: Map::new(),
54            exit_code: if matches!(self.exit_code, Field::Missing) {
55                Field::Null
56            } else {
57                self.exit_code.clone()
58            },
59            duration: if matches!(self.duration, Field::Missing) {
60                Field::Null
61            } else {
62                self.duration.clone()
63            },
64            truncated: if matches!(self.truncated, Field::Missing) {
65                Field::Null
66            } else {
67                self.truncated.clone()
68            },
69            verdict: self.verdict,
70        }
71    }
72}
73
74fn take<T: serde::de::DeserializeOwned>(
75    fields: &mut Map<String, Value>,
76    key: &str,
77) -> Result<Field<T>, serde_json::Error> {
78    match fields.remove(key) {
79        None => Ok(Field::Missing),
80        Some(Value::Null) => Ok(Field::Null),
81        Some(value) => serde_json::from_value(value).map(Field::Value),
82    }
83}
84
85fn insert<T: Serialize>(fields: &mut Map<String, Value>, key: &str, value: &Field<T>) {
86    match value {
87        Field::Missing => {}
88        Field::Null => {
89            fields.insert(key.into(), Value::Null);
90        }
91        Field::Value(value) => {
92            fields.insert(
93                key.into(),
94                serde_json::to_value(value).expect("result field is JSON"),
95            );
96        }
97    }
98}
99
100impl ExecutionResult {
101    pub fn verdict(&self) -> Verdict {
102        match self {
103            Self::Owner(result) => result.verdict,
104            Self::Runtime(result) => result.verdict,
105        }
106    }
107
108    pub fn owner_fields(&self) -> &Map<String, Value> {
109        match self {
110            Self::Owner(result) => &result.fields,
111            Self::Runtime(result) => &result.fields,
112        }
113    }
114
115    /// JSON is materialized only at presentation/schema/serialization boundaries.
116    pub fn to_json(&self) -> Value {
117        let mut fields = self.owner_fields().clone();
118        fields.insert(
119            "verdict".into(),
120            serde_json::to_value(self.verdict()).expect("verdict is JSON"),
121        );
122        if let Self::Runtime(result) = self {
123            insert(&mut fields, "exitCode", &result.exit_code);
124            let duration = match result.duration {
125                Field::Missing => Field::Missing,
126                Field::Null => Field::Null,
127                Field::Value(duration) => Field::Value(
128                    u64::try_from(duration.as_millis())
129                        .expect("saved runtime duration fits u64 milliseconds"),
130                ),
131            };
132            insert(&mut fields, "durationMs", &duration);
133            insert(&mut fields, "truncated", &result.truncated);
134            insert(&mut fields, "stdout", &result.stdout);
135            insert(&mut fields, "stderr", &result.stderr);
136        }
137        Value::Object(fields)
138    }
139}
140
141impl From<ValidatedResult> for ExecutionResult {
142    fn from(result: ValidatedResult) -> Self {
143        Self::Owner(result)
144    }
145}
146
147impl TryFrom<Value> for ExecutionResult {
148    type Error = serde_json::Error;
149    fn try_from(value: Value) -> Result<Self, Self::Error> {
150        let mut fields: Map<String, Value> = serde_json::from_value(value)?;
151        let verdict = serde_json::from_value(fields.remove("verdict").unwrap_or(Value::Null))?;
152        // Owner schemas reserve these runtime fields. `truncated` alone remains a
153        // legitimate owner property and must not turn an owner result into Runtime.
154        if ["exitCode", "durationMs", "stdout", "stderr"]
155            .iter()
156            .any(|key| fields.contains_key(*key))
157        {
158            let duration = match take::<u64>(&mut fields, "durationMs")? {
159                Field::Missing => Field::Missing,
160                Field::Null => Field::Null,
161                Field::Value(ms) => Field::Value(Duration::from_millis(ms)),
162            };
163            Ok(Self::Runtime(RuntimeResult {
164                verdict,
165                exit_code: take(&mut fields, "exitCode")?,
166                duration,
167                truncated: take(&mut fields, "truncated")?,
168                stdout: take(&mut fields, "stdout")?,
169                stderr: take(&mut fields, "stderr")?,
170                fields,
171            }))
172        } else {
173            Ok(Self::Owner(ValidatedResult { verdict, fields }))
174        }
175    }
176}
177
178impl Serialize for ExecutionResult {
179    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
180        self.to_json().serialize(serializer)
181    }
182}
183impl<'de> Deserialize<'de> for ExecutionResult {
184    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
185        Self::try_from(Value::deserialize(deserializer)?).map_err(serde::de::Error::custom)
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use serde_json::json;
193
194    #[test]
195    fn saved_envelopes_round_trip_without_tags_or_field_changes() {
196        for value in [
197            json!({"verdict":"GREEN","approved":true,"nested":{"a":[1,null]},"truncated":"owner"}),
198            json!({"verdict":"RED","exitCode":7,"durationMs":123,"stdout":"out","stderr":"err","truncated":true}),
199            json!({"verdict":"RED","exitCode":7,"durationMs":123,"truncated":false}),
200            json!({"verdict":"RED","exitCode":null,"durationMs":null,"truncated":null,"future":[]}),
201            json!({"verdict":"GREEN","durationMs":u64::MAX}),
202        ] {
203            let text = serde_json::to_string(&value).unwrap();
204            let result: ExecutionResult = serde_json::from_str(&text).unwrap();
205            assert_eq!(serde_json::to_string(&result).unwrap(), text);
206        }
207    }
208
209    #[test]
210    fn verdict_and_runtime_metadata_are_parsed_at_the_edge() {
211        for value in [
212            json!({"verdict":"ERROR"}),
213            json!({"verdict":"GREEN","exitCode":"0"}),
214            json!({"verdict":"RED","durationMs":-1}),
215            json!({"verdict":"RED","stdout":[],"truncated":false}),
216        ] {
217            assert!(ExecutionResult::try_from(value).is_err());
218        }
219        let result = ExecutionResult::try_from(json!({"verdict":"RED","exitCode":7,"durationMs":123,"truncated":true,"stdout":"out","stderr":"err"})).unwrap();
220        let ExecutionResult::Runtime(runtime) = result else {
221            panic!("runtime result");
222        };
223        assert_eq!(runtime.duration.value(), Some(&Duration::from_millis(123)));
224        assert_eq!(
225            ExecutionResult::Runtime(runtime.summary()).to_json(),
226            json!({"verdict":"RED","exitCode":7,"durationMs":123,"truncated":true})
227        );
228    }
229}