Skip to main content

bamboo_domain/session/
loop_control_request.rs

1//! Strict, state-independent command contract for one finite loop.
2//! Routing and execution authority remain with the owning host/runner.
3
4use serde::{Deserialize, Deserializer, Serialize};
5use uuid::Uuid;
6
7pub const MAX_OBJECTIVE_BYTES: usize = 16 * 1024;
8pub const MAX_ITERATIONS: u32 = 100;
9pub const MAX_TOTAL_ROUNDS: u32 = 1000;
10
11/// A command for the executing session only; no target-session routing exists.
12///
13/// Deserialization rejects extra fields for every action, including `status`.
14/// UUIDs and objectives retain their supplied spelling for owner-side operation
15/// fingerprints. State-dependent checks belong to the owning runner.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
17#[serde(tag = "action", rename_all = "snake_case")]
18pub enum LoopControlRequest {
19    Status,
20    Start {
21        operation_id: String,
22        expected_generation: Option<String>,
23        expected_revision: u64,
24        objective: String,
25        max_iterations: u32,
26        max_total_rounds: u32,
27    },
28    Update {
29        operation_id: String,
30        generation: String,
31        expected_revision: u64,
32        objective: Option<String>,
33        max_iterations: Option<u32>,
34        max_total_rounds: Option<u32>,
35    },
36    Stop {
37        operation_id: String,
38        generation: String,
39        expected_revision: u64,
40    },
41}
42
43// An empty struct variant makes deny_unknown_fields apply to status as well as
44// the data-bearing variants. A unit variant's ignored content can otherwise
45// accept extra fields in an internally tagged enum.
46#[derive(Deserialize)]
47#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
48enum LoopControlWireRequest {
49    Status {},
50    Start {
51        operation_id: String,
52        #[serde(default)]
53        expected_generation: Option<String>,
54        expected_revision: u64,
55        objective: String,
56        max_iterations: u32,
57        max_total_rounds: u32,
58    },
59    Update {
60        operation_id: String,
61        generation: String,
62        expected_revision: u64,
63        #[serde(default)]
64        objective: Option<String>,
65        #[serde(default)]
66        max_iterations: Option<u32>,
67        #[serde(default)]
68        max_total_rounds: Option<u32>,
69    },
70    Stop {
71        operation_id: String,
72        generation: String,
73        expected_revision: u64,
74    },
75}
76
77impl<'de> Deserialize<'de> for LoopControlRequest {
78    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
79    where
80        D: Deserializer<'de>,
81    {
82        Ok(match LoopControlWireRequest::deserialize(deserializer)? {
83            LoopControlWireRequest::Status {} => Self::Status,
84            LoopControlWireRequest::Start {
85                operation_id,
86                expected_generation,
87                expected_revision,
88                objective,
89                max_iterations,
90                max_total_rounds,
91            } => Self::Start {
92                operation_id,
93                expected_generation,
94                expected_revision,
95                objective,
96                max_iterations,
97                max_total_rounds,
98            },
99            LoopControlWireRequest::Update {
100                operation_id,
101                generation,
102                expected_revision,
103                objective,
104                max_iterations,
105                max_total_rounds,
106            } => Self::Update {
107                operation_id,
108                generation,
109                expected_revision,
110                objective,
111                max_iterations,
112                max_total_rounds,
113            },
114            LoopControlWireRequest::Stop {
115                operation_id,
116                generation,
117                expected_revision,
118            } => Self::Stop {
119                operation_id,
120                generation,
121                expected_revision,
122            },
123        })
124    }
125}
126
127impl LoopControlRequest {
128    pub fn action(&self) -> &'static str {
129        match self {
130            Self::Status => "status",
131            Self::Start { .. } => "start",
132            Self::Update { .. } => "update",
133            Self::Stop { .. } => "stop",
134        }
135    }
136
137    pub fn validate(&self) -> Result<(), String> {
138        match self {
139            Self::Status => Ok(()),
140            Self::Start {
141                operation_id,
142                expected_generation,
143                objective,
144                max_iterations,
145                max_total_rounds,
146                ..
147            } => {
148                validate_uuid("operation_id", operation_id)?;
149                if let Some(generation) = expected_generation {
150                    validate_uuid("expected_generation", generation)?;
151                }
152                validate_objective(objective)?;
153                validate_bound("max_iterations", *max_iterations, MAX_ITERATIONS)?;
154                validate_bound("max_total_rounds", *max_total_rounds, MAX_TOTAL_ROUNDS)
155            }
156            Self::Update {
157                operation_id,
158                generation,
159                objective,
160                max_iterations,
161                max_total_rounds,
162                ..
163            } => {
164                validate_uuid("operation_id", operation_id)?;
165                validate_uuid("generation", generation)?;
166                if objective.is_none() && max_iterations.is_none() && max_total_rounds.is_none() {
167                    return Err("loop_control.update requires at least one patch field".into());
168                }
169                if let Some(objective) = objective {
170                    validate_objective(objective)?;
171                }
172                if let Some(max_iterations) = max_iterations {
173                    validate_bound("max_iterations", *max_iterations, MAX_ITERATIONS)?;
174                }
175                if let Some(max_total_rounds) = max_total_rounds {
176                    validate_bound("max_total_rounds", *max_total_rounds, MAX_TOTAL_ROUNDS)?;
177                }
178                Ok(())
179            }
180            Self::Stop {
181                operation_id,
182                generation,
183                ..
184            } => {
185                validate_uuid("operation_id", operation_id)?;
186                validate_uuid("generation", generation)
187            }
188        }
189    }
190}
191
192fn validate_uuid(field: &str, value: &str) -> Result<(), String> {
193    Uuid::parse_str(value)
194        .map(|_| ())
195        .map_err(|_| format!("loop_control.{field} must be a valid UUID"))
196}
197
198fn validate_objective(objective: &str) -> Result<(), String> {
199    if objective.trim().is_empty() || objective.len() > MAX_OBJECTIVE_BYTES {
200        return Err(format!(
201            "loop_control.objective must contain non-whitespace text and be at most {MAX_OBJECTIVE_BYTES} UTF-8 bytes"
202        ));
203    }
204    Ok(())
205}
206
207fn validate_bound(field: &str, value: u32, ceiling: u32) -> Result<(), String> {
208    if !(1..=ceiling).contains(&value) {
209        return Err(format!(
210            "loop_control.{field} must be between 1 and {ceiling}"
211        ));
212    }
213    Ok(())
214}
215
216/// Parse strict action fields and validate only state-independent constraints.
217pub fn parse_loop_control_request(arguments: &str) -> Result<LoopControlRequest, String> {
218    let request: LoopControlRequest = serde_json::from_str(arguments)
219        .map_err(|error| format!("invalid loop_control arguments: {error}"))?;
220    request.validate()?;
221    Ok(request)
222}