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    Yellow,
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::Yellow => Some(Self::Yellow),
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    WaitingHuman,
88    Cancelling,
89    Green { satisfied: bool },
90    Red,
91    Incomplete,
92    Yellow,
93    Error,
94    Cancelled,
95}
96impl ResultState {
97    fn from_observed(state: State, satisfied: bool) -> Self {
98        match state {
99            State::Preparing => Self::Preparing,
100            State::Running => Self::Running,
101            State::Waiting => Self::Waiting,
102            State::WaitingHuman => Self::WaitingHuman,
103            State::Cancelling => Self::Cancelling,
104            State::Green => Self::Green { satisfied },
105            State::Red => Self::Red,
106            State::Incomplete => Self::Incomplete,
107            State::Yellow => Self::Yellow,
108            State::Error => Self::Error,
109            State::Cancelled => Self::Cancelled,
110        }
111    }
112    pub fn state(self) -> State {
113        match self {
114            Self::Preparing => State::Preparing,
115            Self::Running => State::Running,
116            Self::Waiting => State::Waiting,
117            Self::WaitingHuman => State::WaitingHuman,
118            Self::Cancelling => State::Cancelling,
119            Self::Green { .. } => State::Green,
120            Self::Red => State::Red,
121            Self::Incomplete => State::Incomplete,
122            Self::Yellow => State::Yellow,
123            Self::Error => State::Error,
124            Self::Cancelled => State::Cancelled,
125        }
126    }
127}
128#[derive(Debug, Clone)]
129pub struct RunResultPage {
130    pub run: RunId,
131    pub phase: ResultState,
132    pub findings: Vec<Finding>,
133    pub next_cursor: Option<ResultCursor>,
134}
135impl RunResultPage {
136    pub fn state(&self) -> State {
137        self.phase.state()
138    }
139    pub fn ready(&self) -> bool {
140        self.state().is_terminal()
141    }
142    pub fn verdict(&self) -> Option<RunVerdict> {
143        RunVerdict::from_state(self.state())
144    }
145    pub fn satisfied(&self) -> bool {
146        matches!(self.phase, ResultState::Green { satisfied: true })
147    }
148}
149impl Serialize for RunResultPage {
150    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
151        // Preserve the flat CLI/MCP wire format; all status flags are derived here.
152        let mut output = serializer.serialize_map(None)?;
153        output.serialize_entry("run", &self.run)?;
154        output.serialize_entry("state", &self.state())?;
155        output.serialize_entry("ready", &self.ready())?;
156        output.serialize_entry("verdict", &self.verdict())?;
157        output.serialize_entry("satisfied", &self.satisfied())?;
158        output.serialize_entry("findings", &self.findings)?;
159        output.serialize_entry("next_cursor", &self.next_cursor)?;
160        output.end()
161    }
162}
163pub(super) fn finding(request: &crate::store::Request) -> Finding {
164    let summary = if let Some(error) = request.error() {
165        error.to_owned()
166    } else if let Some(crate::store::ExecutionResult::Runtime(result)) = request.result() {
167        match result.exit_code.value() {
168            Some(code) => format!("Runtime check exited {code}."),
169            None => format!("Runtime check: {}.", request.status()),
170        }
171    } else {
172        request.status().to_string()
173    };
174    Finding {
175        request: request.id.clone(),
176        eval: request.eval_id.clone(),
177        state: request.status(),
178        summary: summary.chars().take(SUMMARY_CHARS).collect(),
179    }
180}
181pub async fn result(
182    context: &Context,
183    id: &RunId,
184    cursor: Option<ResultCursor>,
185) -> Result<RunResultPage, String> {
186    let observation = observe::status(context, id).await?;
187    if !observation.state.is_terminal() {
188        if cursor.is_some() {
189            return Err("A running Run has no result cursor.".into());
190        }
191        return Ok(RunResultPage {
192            run: id.clone(),
193            phase: ResultState::from_observed(observation.state, false),
194            findings: Vec::new(),
195            next_cursor: None,
196        });
197    }
198    let state_path = context.state.clone();
199    let run_id = id.clone();
200    let (view, details) = crate::task::joined(
201        tokio::task::spawn_blocking(move || {
202            let mut reader = crate::store::reader::Reader::default();
203            reader.snapshot(&state_path, |db, cache, state| {
204                cache
205                    .selected(db, state, &run_id, true)?
206                    .ok_or_else(|| crate::store::receipts::Error::Invalid("Run not found.".into()))
207            })
208        })
209        .await,
210    )
211    .map_err(|e| e.to_string())??;
212    let state = observe::state(&view.run, &view.requests);
213    let revision =
214        Sha256Digest::of(&serde_json::to_vec(&(&view, &details)).map_err(|e| e.to_string())?);
215    let offset = if let Some(cursor) = cursor {
216        if cursor.run != *id || cursor.revision != revision {
217            return Err("Expired or foreign result cursor.".into());
218        }
219        cursor.offset
220    } else {
221        0
222    };
223    if offset > view.requests.len() {
224        return Err("Invalid result cursor offset.".into());
225    }
226    let findings = if state.is_terminal() {
227        view.requests
228            .iter()
229            .skip(offset)
230            .take(PAGE_SIZE)
231            .map(|request| finding(request))
232            .scan(0, |bytes, finding| {
233                let size = serde_json::to_vec(&finding)
234                    .expect("typed finding serializes")
235                    .len()
236                    + 1;
237                *bytes += size;
238                (*bytes <= FINDING_BYTES).then_some(finding)
239            })
240            .collect::<Vec<_>>()
241    } else {
242        Vec::new()
243    };
244    let next = offset + findings.len();
245    let next_cursor = (state.is_terminal() && next < view.requests.len()).then(|| ResultCursor {
246        run: id.clone(),
247        revision,
248        offset: next,
249    });
250    Ok(RunResultPage {
251        run: id.clone(),
252        phase: ResultState::from_observed(
253            state,
254            view.run.validation.snapshot().is_some_and(|validation| {
255                matches!(validation.satisfied, crate::config::Field::Value(true))
256            }),
257        ),
258        findings,
259        next_cursor,
260    })
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use serde_json::json;
267
268    #[test]
269    fn result_phase_is_the_only_source_of_wire_readiness_and_verdict() {
270        let page = |phase| RunResultPage {
271            run: "run-wire".parse().unwrap(),
272            phase,
273            findings: Vec::new(),
274            next_cursor: None,
275        };
276        assert_eq!(
277            serde_json::to_value(page(ResultState::Running)).unwrap(),
278            json!({
279                "run":"run-wire","state":"running","ready":false,"verdict":null,
280                "satisfied":false,"findings":[],"next_cursor":null,
281            })
282        );
283        assert_eq!(
284            serde_json::to_value(page(ResultState::Green { satisfied: true })).unwrap(),
285            json!({
286                "run":"run-wire","state":"green","ready":true,"verdict":"GREEN",
287                "satisfied":true,"findings":[],"next_cursor":null,
288            })
289        );
290        let red = serde_json::to_value(page(ResultState::Red)).unwrap();
291        assert_eq!(red["verdict"], "RED");
292        assert_eq!(red["ready"], true);
293        assert_eq!(red["satisfied"], false);
294    }
295}