1use super::{Context, State, observe};
3use crate::types::{EvalId, RequestId, RequestStatus, RunId, Sha256Digest};
4use serde::{Deserialize, Serialize, ser::SerializeMap};
5const SUMMARY_CHARS: usize = 512;
7const PAGE_SIZE: usize = 20;
9const 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#[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 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}