Skip to main content

rch_common/
exec_response.rs

1//! `rch exec` execution-location contract
2//! (bd-session-history-remediation-ocv9i.13.3).
3//!
4//! Agents kept inferring *where* a command ran from log prose. Every explicit
5//! `rch exec` JSON response must instead state it outright: local-or-remote, the
6//! selected worker, the classification, a decisive reason code, the admission
7//! summary, the proof/fallback policy, and the remediation. This module is that
8//! composite contract — [`ExecResponse`] — assembled from the pieces already
9//! landed: the exec policy ([`crate::exec_policy`]), the admission vocabulary +
10//! aggregation ([`crate::admission_rejection`]), and the next-action
11//! recommendations ([`crate::admission_recommendation`]).
12//!
13//! The cardinal invariant: `local_or_remote` is always present and definite, and
14//! a `selected_worker` exists **iff** the location is `Remote` — never the
15//! ambiguity that motivated the bead.
16
17use 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/// Where a command definitively executed.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum ExecutionLocation {
28    /// Ran on a remote worker.
29    Remote,
30    /// Ran locally (fallback or non-compilation).
31    Local,
32    /// Did not run (rejected / fail-closed).
33    NotExecuted,
34}
35
36impl ExecutionLocation {
37    /// Stable lowercase token.
38    #[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/// The composite `rch exec` machine response.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct ExecResponse {
51    /// Definite execution location.
52    pub local_or_remote: ExecutionLocation,
53    /// The worker, present iff `local_or_remote == Remote`.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub selected_worker: Option<String>,
56    /// Whether the command classified as a compilation.
57    pub is_compilation: bool,
58    /// Compilation family (e.g. `cargo_build`), when classified.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub family: Option<String>,
61    /// Decisive incident reason code, when there is one.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub reason_code: Option<IncidentReasonCode>,
64    /// Aggregated admission rejection summary, when admission was attempted.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub admission: Option<AdmissionRejectionSummary>,
67    /// The exec-policy disposition that produced this outcome.
68    pub disposition: ExecDisposition,
69    /// Whether proof/strict mode was in force.
70    pub proof_mode: bool,
71    /// Next-action remediation (may be empty on success).
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub remediation: Vec<Recommendation>,
74    /// Canonical placement control snapshot (`requested_worker`,
75    /// `strict_remote_policy`, `queue_policy`, `visibility_mode`,
76    /// `wait_timeout_ms`, `target_dir_policy`). Omitted from the wire form when
77    /// no control was explicitly set, so the simple-success contract is
78    /// unchanged. `selected_worker` above is the *effective* worker — the
79    /// pairing of requested-vs-effective is what makes a steered build visible
80    /// rather than a silent swap (bd-...remediation-ocv9i.13.5).
81    #[serde(default, skip_serializing_if = "ControlState::is_empty")]
82    pub control: ControlState,
83    /// Concise human/agent-facing detail.
84    pub detail: String,
85}
86
87impl ExecResponse {
88    /// The contract invariant: a worker is recorded iff the location is remote.
89    #[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    /// A successful remote execution on `worker`.
100    #[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    /// A local fallback (with the reason it fell back).
118    #[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    /// A fail-closed proof refusal (nothing ran), with the admission summary and
140    /// derived remediation.
141    #[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    /// An explicit proof-mode non-compilation rejection.
160    #[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    /// Daemon unavailable — fell back to local (fail-open).
178    #[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    /// Attach the canonical placement control snapshot.
196    #[must_use]
197    pub fn with_control(mut self, control: ControlState) -> Self {
198        self.control = control;
199        self
200    }
201
202    /// The *effective* worker the command ran on (an alias for
203    /// `selected_worker`, named to pair with the control's `requested_worker`).
204    #[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    // --- The five required golden scenarios ---------------------------------
225
226    #[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        // Admission summary + remediation are present for the handoff.
276        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    // --- The contract invariant --------------------------------------------
302
303    #[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        // A hand-built inconsistent record fails the invariant.
314        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        // The simple-success contract is unchanged: with no explicit controls,
333        // the `control` block does not appear in the JSON.
334        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        // selected_worker is the effective worker; the control carries what was
350        // requested — a steered build is therefore visible, not silent.
351        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        // local_or_remote, is_compilation, disposition, proof_mode, detail are
364        // never omitted from the wire form.
365        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}