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 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#[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 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}