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(super) fn finding(request: &crate::store::Request) -> Finding {
161    let summary = if let Some(error) = request.error() {
162        error.to_owned()
163    } else if let Some(crate::store::ExecutionResult::Runtime(result)) = request.result() {
164        match result.exit_code.value() {
165            Some(code) => format!("Runtime check exited {code}."),
166            None => format!("Runtime check: {}.", request.status()),
167        }
168    } else {
169        request.status().to_string()
170    };
171    Finding {
172        request: request.id.clone(),
173        eval: request.eval_id.clone(),
174        state: request.status(),
175        summary: summary.chars().take(SUMMARY_CHARS).collect(),
176    }
177}
178pub async fn result(
179    context: &Context,
180    id: &RunId,
181    cursor: Option<ResultCursor>,
182) -> Result<RunResultPage, String> {
183    let observation = observe::status(context, id).await?;
184    if !observation.state.is_terminal() {
185        if cursor.is_some() {
186            return Err("A running Run has no result cursor.".into());
187        }
188        return Ok(RunResultPage {
189            run: id.clone(),
190            phase: ResultState::from_observed(observation.state, false),
191            findings: Vec::new(),
192            next_cursor: None,
193        });
194    }
195    let state_path = context.state.clone();
196    let run_id = id.clone();
197    let (view, details) = crate::task::joined(
198        tokio::task::spawn_blocking(move || {
199            let mut reader = crate::store::reader::Reader::default();
200            reader.snapshot(&state_path, |db, cache, state| {
201                cache
202                    .selected(db, state, &run_id, None, true)?
203                    .ok_or_else(|| crate::store::receipts::Error::Invalid("Run not found.".into()))
204            })
205        })
206        .await,
207    )
208    .map_err(|e| e.to_string())??;
209    let state = observe::state(&view.run, &view.requests);
210    let revision =
211        Sha256Digest::of(&serde_json::to_vec(&(&view, &details)).map_err(|e| e.to_string())?);
212    let offset = if let Some(cursor) = cursor {
213        if cursor.run != *id || cursor.revision != revision {
214            return Err("Expired or foreign result cursor.".into());
215        }
216        cursor.offset
217    } else {
218        0
219    };
220    if offset > view.requests.len() {
221        return Err("Invalid result cursor offset.".into());
222    }
223    let findings = if state.is_terminal() {
224        view.requests
225            .iter()
226            .skip(offset)
227            .take(PAGE_SIZE)
228            .map(finding)
229            .scan(0, |bytes, finding| {
230                let size = serde_json::to_vec(&finding)
231                    .expect("typed finding serializes")
232                    .len()
233                    + 1;
234                *bytes += size;
235                (*bytes <= FINDING_BYTES).then_some(finding)
236            })
237            .collect::<Vec<_>>()
238    } else {
239        Vec::new()
240    };
241    let next = offset + findings.len();
242    let next_cursor = (state.is_terminal() && next < view.requests.len()).then(|| ResultCursor {
243        run: id.clone(),
244        revision,
245        offset: next,
246    });
247    Ok(RunResultPage {
248        run: id.clone(),
249        phase: ResultState::from_observed(
250            state,
251            view.run.validation.snapshot().is_some_and(|validation| {
252                matches!(validation.satisfied, crate::config::Field::Value(true))
253            }),
254        ),
255        findings,
256        next_cursor,
257    })
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use serde_json::json;
264
265    #[test]
266    fn result_phase_is_the_only_source_of_wire_readiness_and_verdict() {
267        let page = |phase| RunResultPage {
268            run: "run-wire".parse().unwrap(),
269            phase,
270            findings: Vec::new(),
271            next_cursor: None,
272        };
273        assert_eq!(
274            serde_json::to_value(page(ResultState::Running)).unwrap(),
275            json!({
276                "run":"run-wire","state":"running","ready":false,"verdict":null,
277                "satisfied":false,"findings":[],"next_cursor":null,
278            })
279        );
280        assert_eq!(
281            serde_json::to_value(page(ResultState::Green { satisfied: true })).unwrap(),
282            json!({
283                "run":"run-wire","state":"green","ready":true,"verdict":"GREEN",
284                "satisfied":true,"findings":[],"next_cursor":null,
285            })
286        );
287        let red = serde_json::to_value(page(ResultState::Red)).unwrap();
288        assert_eq!(red["verdict"], "RED");
289        assert_eq!(red["ready"], true);
290        assert_eq!(red["satisfied"], false);
291    }
292}