Skip to main content

artifactize_core/runs/
result.rs

1//! Bounded pages derived from a saved Run/request/execution read snapshot, never a second store.
2use super::{Context, State, observe};
3use crate::types::{EvalId, RequestId, RequestStatus, RunId, Sha256Digest};
4use serde::{Deserialize, Serialize, ser::SerializeMap};
5/// Compact findings stay useful within MCP's response bound even with long owner summaries.
6const SUMMARY_CHARS: usize = 512;
7/// Shared page size; interfaces cannot accidentally emit an unbounded findings list.
8const PAGE_SIZE: usize = 20;
9/// Leave room for the envelope and MCP's structured-plus-text mirror under 64 KiB.
10const FINDING_BYTES: usize = 24 * 1024;
11#[derive(Debug, Clone, Copy, Serialize)]
12#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
13pub enum RunVerdict {
14    Green,
15    Red,
16    Incomplete,
17    SignoffsPending,
18    Error,
19    Cancelled,
20}
21impl RunVerdict {
22    fn from_state(state: State) -> Option<Self> {
23        match state {
24            State::Green => Some(Self::Green),
25            State::Red => Some(Self::Red),
26            State::Incomplete => Some(Self::Incomplete),
27            State::SignoffsPending => Some(Self::SignoffsPending),
28            State::Error => Some(Self::Error),
29            State::Cancelled => Some(Self::Cancelled),
30            _ => None,
31        }
32    }
33}
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(try_from = "String", into = "String")]
36pub struct ResultCursor {
37    run: RunId,
38    revision: Sha256Digest,
39    offset: usize,
40}
41impl TryFrom<String> for ResultCursor {
42    type Error = String;
43    fn try_from(value: String) -> Result<Self, String> {
44        let mut parts = value.rsplitn(3, ':');
45        let offset = parts
46            .next()
47            .and_then(|part| part.parse().ok())
48            .ok_or("Invalid result cursor.")?;
49        let revision = parts
50            .next()
51            .ok_or("Invalid result cursor.")?
52            .parse::<Sha256Digest>()
53            .map_err(|_| "Invalid result cursor.")?;
54        let run = parts.next().ok_or("Invalid result cursor.")?.parse()?;
55        Ok(Self {
56            run,
57            revision,
58            offset,
59        })
60    }
61}
62impl From<ResultCursor> for String {
63    fn from(cursor: ResultCursor) -> Self {
64        format!("{}:{}:{}", cursor.run, cursor.revision, cursor.offset)
65    }
66}
67impl std::str::FromStr for ResultCursor {
68    type Err = String;
69    fn from_str(value: &str) -> Result<Self, String> {
70        value.to_owned().try_into()
71    }
72}
73#[derive(Debug, Clone, Serialize)]
74pub struct Finding {
75    pub request: RequestId,
76    pub eval: EvalId,
77    pub state: RequestStatus,
78    pub summary: String,
79}
80/// One result phase owns readiness and verdict. Only GREEN can carry recorded
81/// satisfaction; older GREEN Runs without validation may still lack that proof.
82#[derive(Debug, Clone, Copy)]
83pub enum ResultState {
84    Preparing,
85    Running,
86    Waiting,
87    Cancelling,
88    Green { satisfied: bool },
89    Red,
90    Incomplete,
91    SignoffsPending,
92    Error,
93    Cancelled,
94}
95impl ResultState {
96    fn from_observed(state: State, satisfied: bool) -> Self {
97        match state {
98            State::Preparing => Self::Preparing,
99            State::Running => Self::Running,
100            State::Waiting => Self::Waiting,
101            State::Cancelling => Self::Cancelling,
102            State::Green => Self::Green { satisfied },
103            State::Red => Self::Red,
104            State::Incomplete => Self::Incomplete,
105            State::SignoffsPending => Self::SignoffsPending,
106            State::Error => Self::Error,
107            State::Cancelled => Self::Cancelled,
108        }
109    }
110    pub fn state(self) -> State {
111        match self {
112            Self::Preparing => State::Preparing,
113            Self::Running => State::Running,
114            Self::Waiting => State::Waiting,
115            Self::Cancelling => State::Cancelling,
116            Self::Green { .. } => State::Green,
117            Self::Red => State::Red,
118            Self::Incomplete => State::Incomplete,
119            Self::SignoffsPending => State::SignoffsPending,
120            Self::Error => State::Error,
121            Self::Cancelled => State::Cancelled,
122        }
123    }
124}
125#[derive(Debug, Clone)]
126pub struct RunResultPage {
127    pub run: RunId,
128    pub phase: ResultState,
129    pub findings: Vec<Finding>,
130    pub next_cursor: Option<ResultCursor>,
131}
132impl RunResultPage {
133    pub fn state(&self) -> State {
134        self.phase.state()
135    }
136    pub fn ready(&self) -> bool {
137        self.state().is_terminal()
138    }
139    pub fn verdict(&self) -> Option<RunVerdict> {
140        RunVerdict::from_state(self.state())
141    }
142    pub fn satisfied(&self) -> bool {
143        matches!(self.phase, ResultState::Green { satisfied: true })
144    }
145}
146impl Serialize for RunResultPage {
147    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
148        // Preserve the flat CLI/MCP wire format; all status flags are derived here.
149        let mut output = serializer.serialize_map(None)?;
150        output.serialize_entry("run", &self.run)?;
151        output.serialize_entry("state", &self.state())?;
152        output.serialize_entry("ready", &self.ready())?;
153        output.serialize_entry("verdict", &self.verdict())?;
154        output.serialize_entry("satisfied", &self.satisfied())?;
155        output.serialize_entry("findings", &self.findings)?;
156        output.serialize_entry("next_cursor", &self.next_cursor)?;
157        output.end()
158    }
159}
160pub async fn result(
161    context: &Context,
162    id: &RunId,
163    cursor: Option<ResultCursor>,
164) -> Result<RunResultPage, String> {
165    let observation = observe::status(context, id).await?;
166    if !observation.state.is_terminal() {
167        if cursor.is_some() {
168            return Err("A running Run has no result cursor.".into());
169        }
170        return Ok(RunResultPage {
171            run: id.clone(),
172            phase: ResultState::from_observed(observation.state, false),
173            findings: Vec::new(),
174            next_cursor: None,
175        });
176    }
177    let state_path = context.state.clone();
178    let run_id = id.clone();
179    let (view, details) = crate::task::joined(
180        tokio::task::spawn_blocking(move || {
181            let mut reader = crate::store::reader::Reader::default();
182            reader.snapshot(&state_path, |db, cache, state| {
183                cache
184                    .selected(db, state, &run_id, None, true)?
185                    .ok_or_else(|| crate::store::receipts::Error::Invalid("Run not found.".into()))
186            })
187        })
188        .await,
189    )
190    .map_err(|e| e.to_string())??;
191    let state = observe::state(&view.run, &view.requests);
192    let revision =
193        Sha256Digest::of(&serde_json::to_vec(&(&view, &details)).map_err(|e| e.to_string())?);
194    let offset = if let Some(cursor) = cursor {
195        if cursor.run != *id || cursor.revision != revision {
196            return Err("Expired or foreign result cursor.".into());
197        }
198        cursor.offset
199    } else {
200        0
201    };
202    if offset > view.requests.len() {
203        return Err("Invalid result cursor offset.".into());
204    }
205    let findings = if state.is_terminal() {
206        view.requests
207            .iter()
208            .skip(offset)
209            .take(PAGE_SIZE)
210            .map(|request| {
211                let summary = if let Some(error) = request.error() {
212                    error.to_owned()
213                } else if let Some(crate::store::ExecutionResult::Runtime(result)) =
214                    request.result()
215                {
216                    match result.exit_code.value() {
217                        Some(code) => format!("Runtime check exited {code}."),
218                        None => format!("Runtime check: {}.", request.status()),
219                    }
220                } else {
221                    request.status().to_string()
222                };
223                Finding {
224                    request: request.id.clone(),
225                    eval: request.eval_id.clone(),
226                    state: request.status(),
227                    summary: summary.chars().take(SUMMARY_CHARS).collect(),
228                }
229            })
230            .scan(0, |bytes, finding| {
231                let size = serde_json::to_vec(&finding)
232                    .expect("typed finding serializes")
233                    .len()
234                    + 1;
235                *bytes += size;
236                (*bytes <= FINDING_BYTES).then_some(finding)
237            })
238            .collect::<Vec<_>>()
239    } else {
240        Vec::new()
241    };
242    let next = offset + findings.len();
243    let next_cursor = (state.is_terminal() && next < view.requests.len()).then(|| ResultCursor {
244        run: id.clone(),
245        revision,
246        offset: next,
247    });
248    Ok(RunResultPage {
249        run: id.clone(),
250        phase: ResultState::from_observed(
251            state,
252            view.run.validation.snapshot().is_some_and(|validation| {
253                matches!(validation.satisfied, crate::config::Field::Value(true))
254            }),
255        ),
256        findings,
257        next_cursor,
258    })
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use serde_json::json;
265
266    #[test]
267    fn result_phase_is_the_only_source_of_wire_readiness_and_verdict() {
268        let page = |phase| RunResultPage {
269            run: "run-wire".parse().unwrap(),
270            phase,
271            findings: Vec::new(),
272            next_cursor: None,
273        };
274        assert_eq!(
275            serde_json::to_value(page(ResultState::Running)).unwrap(),
276            json!({
277                "run":"run-wire","state":"running","ready":false,"verdict":null,
278                "satisfied":false,"findings":[],"next_cursor":null,
279            })
280        );
281        assert_eq!(
282            serde_json::to_value(page(ResultState::Green { satisfied: true })).unwrap(),
283            json!({
284                "run":"run-wire","state":"green","ready":true,"verdict":"GREEN",
285                "satisfied":true,"findings":[],"next_cursor":null,
286            })
287        );
288        let red = serde_json::to_value(page(ResultState::Red)).unwrap();
289        assert_eq!(red["verdict"], "RED");
290        assert_eq!(red["ready"], true);
291        assert_eq!(red["satisfied"], false);
292    }
293}