1use serde::{Deserialize, Serialize};
18
19use crate::admission_recommendation::{Recommendation, recommend_for_summary};
20use crate::admission_rejection::AdmissionRejectionSummary;
21use crate::exec_policy::ExecDisposition;
22use crate::incident::{ControlState, IncidentReasonCode};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum ExecutionLocation {
28 Remote,
30 Local,
32 NotExecuted,
34}
35
36impl ExecutionLocation {
37 #[must_use]
39 pub fn as_str(self) -> &'static str {
40 match self {
41 ExecutionLocation::Remote => "remote",
42 ExecutionLocation::Local => "local",
43 ExecutionLocation::NotExecuted => "not_executed",
44 }
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct ExecResponse {
51 pub local_or_remote: ExecutionLocation,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub selected_worker: Option<String>,
56 pub is_compilation: bool,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub family: Option<String>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub reason_code: Option<IncidentReasonCode>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub admission: Option<AdmissionRejectionSummary>,
67 pub disposition: ExecDisposition,
69 pub proof_mode: bool,
71 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub remediation: Vec<Recommendation>,
74 #[serde(default, skip_serializing_if = "ControlState::is_empty")]
82 pub control: ControlState,
83 pub detail: String,
85}
86
87impl ExecResponse {
88 #[must_use]
90 pub fn invariant_holds(&self) -> bool {
91 match self.local_or_remote {
92 ExecutionLocation::Remote => self.selected_worker.is_some(),
93 ExecutionLocation::Local | ExecutionLocation::NotExecuted => {
94 self.selected_worker.is_none()
95 }
96 }
97 }
98
99 #[must_use]
101 pub fn remote_success(worker: impl Into<String>, family: impl Into<String>) -> Self {
102 Self {
103 local_or_remote: ExecutionLocation::Remote,
104 selected_worker: Some(worker.into()),
105 is_compilation: true,
106 family: Some(family.into()),
107 reason_code: None,
108 admission: None,
109 disposition: ExecDisposition::RunRemote,
110 proof_mode: false,
111 remediation: Vec::new(),
112 control: ControlState::default(),
113 detail: "ran on remote worker".to_string(),
114 }
115 }
116
117 #[must_use]
119 pub fn local_fallback(
120 is_compilation: bool,
121 reason_code: IncidentReasonCode,
122 detail: impl Into<String>,
123 ) -> Self {
124 Self {
125 local_or_remote: ExecutionLocation::Local,
126 selected_worker: None,
127 is_compilation,
128 family: None,
129 reason_code: Some(reason_code),
130 admission: None,
131 disposition: ExecDisposition::RunLocalFallback,
132 proof_mode: false,
133 remediation: Vec::new(),
134 control: ControlState::default(),
135 detail: detail.into(),
136 }
137 }
138
139 #[must_use]
142 pub fn proof_refused(admission: AdmissionRejectionSummary) -> Self {
143 let remediation = recommend_for_summary(&admission, true);
144 Self {
145 local_or_remote: ExecutionLocation::NotExecuted,
146 selected_worker: None,
147 is_compilation: true,
148 family: None,
149 reason_code: Some(IncidentReasonCode::ProofRefusal),
150 admission: Some(admission),
151 disposition: ExecDisposition::Reject,
152 proof_mode: true,
153 remediation,
154 control: ControlState::default(),
155 detail: "proof mode refused before execution".to_string(),
156 }
157 }
158
159 #[must_use]
161 pub fn non_compilation_rejected() -> Self {
162 Self {
163 local_or_remote: ExecutionLocation::NotExecuted,
164 selected_worker: None,
165 is_compilation: false,
166 family: None,
167 reason_code: Some(IncidentReasonCode::ProofRefusal),
168 admission: None,
169 disposition: ExecDisposition::Reject,
170 proof_mode: true,
171 remediation: Vec::new(),
172 control: ControlState::default(),
173 detail: "non-compilation command rejected under proof mode".to_string(),
174 }
175 }
176
177 #[must_use]
179 pub fn daemon_unavailable() -> Self {
180 Self {
181 local_or_remote: ExecutionLocation::Local,
182 selected_worker: None,
183 is_compilation: true,
184 family: None,
185 reason_code: Some(IncidentReasonCode::DaemonSocketRefused),
186 admission: None,
187 disposition: ExecDisposition::RunLocalFallback,
188 proof_mode: false,
189 remediation: Vec::new(),
190 control: ControlState::default(),
191 detail: "daemon unreachable; ran locally (fail-open)".to_string(),
192 }
193 }
194
195 #[must_use]
197 pub fn with_control(mut self, control: ControlState) -> Self {
198 self.control = control;
199 self
200 }
201
202 #[must_use]
205 pub fn effective_worker(&self) -> Option<&str> {
206 self.selected_worker.as_deref()
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use crate::admission_rejection::{
214 AdmissionRejectionCategory, CandidateRejection, aggregate_rejections,
215 };
216
217 fn assert_invariant(r: &ExecResponse) {
218 assert!(
219 r.invariant_holds(),
220 "location/worker invariant violated: {r:?}"
221 );
222 }
223
224 #[test]
227 fn golden_remote_success() {
228 let r = ExecResponse::remote_success("css", "cargo_build");
229 assert_invariant(&r);
230 let v = serde_json::to_value(&r).unwrap();
231 assert_eq!(v["local_or_remote"], "remote");
232 assert_eq!(v["selected_worker"], "css");
233 assert_eq!(v["disposition"], "run_remote");
234 assert!(v.get("reason_code").is_none());
235 assert!(v.get("remediation").is_none(), "no remediation on success");
236 }
237
238 #[test]
239 fn golden_local_fallback() {
240 let r = ExecResponse::local_fallback(
241 true,
242 IncidentReasonCode::LocalFallback,
243 "no admissible worker; ran locally",
244 );
245 assert_invariant(&r);
246 let v = serde_json::to_value(&r).unwrap();
247 assert_eq!(v["local_or_remote"], "local");
248 assert!(v.get("selected_worker").is_none());
249 assert_eq!(v["disposition"], "run_local_fallback");
250 assert_eq!(v["reason_code"], "RCH-I011");
251 }
252
253 #[test]
254 fn golden_fail_closed_proof_refusal() {
255 let admission = aggregate_rejections(
256 2,
257 &[
258 CandidateRejection {
259 worker_id: "a".into(),
260 category: AdmissionRejectionCategory::MissingRustTarget,
261 },
262 CandidateRejection {
263 worker_id: "b".into(),
264 category: AdmissionRejectionCategory::OsArchMismatch,
265 },
266 ],
267 );
268 let r = ExecResponse::proof_refused(admission);
269 assert_invariant(&r);
270 let v = serde_json::to_value(&r).unwrap();
271 assert_eq!(v["local_or_remote"], "not_executed");
272 assert_eq!(v["disposition"], "reject");
273 assert_eq!(v["proof_mode"], true);
274 assert_eq!(v["reason_code"], "RCH-I012");
275 assert_eq!(v["admission"]["rejected"], 2);
277 assert!(!r.remediation.is_empty());
278 assert_eq!(v["remediation"][0]["action"], "fix_worker_capability");
279 }
280
281 #[test]
282 fn golden_non_compilation_rejection() {
283 let r = ExecResponse::non_compilation_rejected();
284 assert_invariant(&r);
285 let v = serde_json::to_value(&r).unwrap();
286 assert_eq!(v["local_or_remote"], "not_executed");
287 assert_eq!(v["is_compilation"], false);
288 assert_eq!(v["disposition"], "reject");
289 }
290
291 #[test]
292 fn golden_daemon_unavailable() {
293 let r = ExecResponse::daemon_unavailable();
294 assert_invariant(&r);
295 let v = serde_json::to_value(&r).unwrap();
296 assert_eq!(v["local_or_remote"], "local");
297 assert_eq!(v["reason_code"], "RCH-I010");
298 assert_eq!(v["disposition"], "run_local_fallback");
299 }
300
301 #[test]
304 fn worker_present_iff_remote() {
305 assert_invariant(&ExecResponse::remote_success("w", "cargo_build"));
306 assert_invariant(&ExecResponse::local_fallback(
307 true,
308 IncidentReasonCode::LocalFallback,
309 "x",
310 ));
311 assert_invariant(&ExecResponse::non_compilation_rejected());
312 assert_invariant(&ExecResponse::daemon_unavailable());
313 let bad = ExecResponse {
315 local_or_remote: ExecutionLocation::Remote,
316 selected_worker: None,
317 ..ExecResponse::remote_success("w", "cargo_build")
318 };
319 assert!(!bad.invariant_holds());
320 }
321
322 #[test]
323 fn response_round_trips() {
324 let r = ExecResponse::daemon_unavailable();
325 let v = serde_json::to_value(&r).unwrap();
326 let back: ExecResponse = serde_json::from_value(v).unwrap();
327 assert_eq!(back, r);
328 }
329
330 #[test]
331 fn empty_control_is_omitted_from_wire() {
332 let r = ExecResponse::remote_success("css", "cargo_build");
335 let v = serde_json::to_value(&r).unwrap();
336 assert!(v.get("control").is_none(), "empty control must be omitted");
337 }
338
339 #[test]
340 fn control_snapshot_round_trips_and_pairs_requested_with_effective() {
341 use crate::incident::ControlState;
342 let r = ExecResponse::remote_success("ovh-a", "cargo_test").with_control(ControlState {
343 requested_worker: Some("css".to_string()),
344 strict_remote_policy: true,
345 queue_policy: Some("no_queue".to_string()),
346 ..ControlState::default()
347 });
348 assert_invariant(&r);
349 assert_eq!(r.effective_worker(), Some("ovh-a"));
352 let v = serde_json::to_value(&r).unwrap();
353 assert_eq!(v["selected_worker"], "ovh-a");
354 assert_eq!(v["control"]["requested_worker"], "css");
355 assert_eq!(v["control"]["strict_remote_policy"], true);
356 assert_eq!(v["control"]["queue_policy"], "no_queue");
357 let back: ExecResponse = serde_json::from_value(v).unwrap();
358 assert_eq!(back, r);
359 }
360
361 #[test]
362 fn required_fields_are_always_present() {
363 for r in [
366 ExecResponse::remote_success("w", "cargo_build"),
367 ExecResponse::non_compilation_rejected(),
368 ExecResponse::daemon_unavailable(),
369 ] {
370 let v = serde_json::to_value(&r).unwrap();
371 for key in [
372 "local_or_remote",
373 "is_compilation",
374 "disposition",
375 "proof_mode",
376 "detail",
377 ] {
378 assert!(v.get(key).is_some(), "missing required field {key}");
379 }
380 }
381 }
382}