1use std::{collections::BTreeMap, io::Write, path::Path, time::Duration};
14
15use serde::{Deserialize, Serialize};
16
17use crate::config::Backend;
18#[cfg(test)]
19mod backend_tests;
20
21pub const FILE: &str = "limits.json";
22const MAX_BYTES: u64 = 64 * 1024;
24const MAX_SLOTS: u32 = 100_000;
27pub const SESSIONS_MAX_BYTES: u64 = 1024 * 1024 * 1024;
29pub const SESSIONS_TARGET_BYTES: u64 = 768 * 1024 * 1024;
31const SESSION_TARGET_NUMERATOR: u64 = 3;
34const SESSION_TARGET_DENOMINATOR: u64 = 4;
35pub const SIGNOFF_IDLE_GRACE: Duration = Duration::from_secs(120);
38pub const MAX_SIGNOFF_IDLE_GRACE: Duration = Duration::from_secs(24 * 60 * 60);
40const GRACE_FIELD: &str = "signoffIdleGraceSeconds";
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
46#[serde(rename_all = "camelCase")]
47pub struct Limits {
48 backends: BTreeMap<Backend, u32>,
49 agent_sessions: AgentSessions,
50 #[serde(rename = "signoffIdleGraceSeconds", serialize_with = "seconds")]
51 signoff_idle_grace: Duration,
52}
53
54fn seconds<S: serde::Serializer>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error> {
55 serializer.serialize_u64(duration.as_secs())
56}
57
58impl Default for Limits {
59 fn default() -> Self {
60 Self {
61 backends: BTreeMap::new(),
62 agent_sessions: AgentSessions::default(),
63 signoff_idle_grace: SIGNOFF_IDLE_GRACE,
64 }
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "camelCase")]
72pub struct AgentSessions {
73 pub enabled: bool,
74 pub max_bytes: u64,
75 pub target_bytes: u64,
76}
77
78impl Default for AgentSessions {
79 fn default() -> Self {
80 Self {
81 enabled: true,
82 max_bytes: SESSIONS_MAX_BYTES,
83 target_bytes: SESSIONS_TARGET_BYTES,
84 }
85 }
86}
87
88#[derive(Deserialize)]
89#[serde(deny_unknown_fields)]
90struct Declared {
91 #[serde(default, deserialize_with = "backend_limits")]
92 backends: BTreeMap<Backend, u32>,
93 #[serde(default, rename = "agentSessions")]
94 agent_sessions: Option<DeclaredSessions>,
95 #[serde(default, rename = "signoffIdleGraceSeconds")]
96 signoff_idle_grace_seconds: Option<u64>,
97}
98
99fn grace(seconds: u64) -> Result<Duration, String> {
101 let grace = Duration::from_secs(seconds);
102 if grace > MAX_SIGNOFF_IDLE_GRACE {
103 return Err(format!(
104 "{GRACE_FIELD} must be at most {}.",
105 MAX_SIGNOFF_IDLE_GRACE.as_secs()
106 ));
107 }
108 Ok(grace)
109}
110
111fn backend_limits<'de, D: serde::Deserializer<'de>>(
112 deserializer: D,
113) -> Result<BTreeMap<Backend, u32>, D::Error> {
114 let names = BTreeMap::<String, u32>::deserialize(deserializer)?;
117 names
118 .into_iter()
119 .map(|(name, limit)| {
120 let backend =
121 serde_json::from_value::<Backend>(serde_json::Value::String(name.clone()))
122 .map_err(|error| serde::de::Error::custom(format!("backends: {error}")))?;
123 if !(1..=MAX_SLOTS).contains(&limit) {
124 return Err(serde::de::Error::custom(format!(
125 "backends.{name} must be between 1 and {MAX_SLOTS}."
126 )));
127 }
128 Ok((backend, limit))
129 })
130 .collect()
131}
132
133#[derive(Deserialize)]
134#[serde(rename_all = "camelCase", deny_unknown_fields)]
135struct DeclaredSessions {
136 enabled: Option<bool>,
137 max_bytes: Option<u64>,
138 target_bytes: Option<u64>,
139}
140
141impl DeclaredSessions {
142 fn resolve(self) -> Result<AgentSessions, String> {
145 let max_bytes = self.max_bytes.unwrap_or(SESSIONS_MAX_BYTES);
146 if max_bytes == 0 {
147 return Err("agentSessions.maxBytes must be at least 1.".into());
148 }
149 let target_bytes = match self.target_bytes {
150 Some(target) if target >= max_bytes => {
151 return Err(format!(
152 "agentSessions.targetBytes ({target}) must be lower than maxBytes ({max_bytes})."
153 ));
154 }
155 Some(target) => target,
156 None if SESSIONS_TARGET_BYTES < max_bytes => SESSIONS_TARGET_BYTES,
157 None => max_bytes / SESSION_TARGET_DENOMINATOR * SESSION_TARGET_NUMERATOR,
158 };
159 Ok(AgentSessions {
160 enabled: self.enabled.unwrap_or(true),
161 max_bytes,
162 target_bytes,
163 })
164 }
165}
166
167impl Limits {
168 pub fn read(state: &Path) -> Result<Self, String> {
172 let path = state.join(FILE);
173 let invalid = |message: String| format!("{}: {message}", crate::platform::path_text(&path));
174 let too_large =
175 || invalid("limits.json must be a regular file no larger than 64 KiB.".into());
176 let file = match crate::platform::open_regular(&path) {
177 Ok(file) => file,
178 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
179 return Ok(Self::default());
180 }
181 Err(error) if error.kind() == std::io::ErrorKind::InvalidInput => {
182 return Err(too_large());
183 }
184 Err(error) => return Err(invalid(error.to_string())),
185 };
186 let mut text = String::new();
187 std::io::Read::read_to_string(&mut std::io::Read::take(&file, MAX_BYTES + 1), &mut text)
188 .map_err(|e| invalid(e.to_string()))?;
189 if text.len() as u64 > MAX_BYTES {
190 return Err(too_large());
191 }
192 let declared: Declared = serde_json::from_str(&text).map_err(|error| {
193 let message = error.to_string();
194 let message = if message.starts_with("backends:") || message.starts_with("backends.") {
197 message
198 .strip_suffix(&format!(
199 " at line {} column {}",
200 error.line(),
201 error.column()
202 ))
203 .unwrap_or(&message)
204 } else {
205 &message
206 };
207 invalid(message.to_owned())
208 })?;
209 let agent_sessions = declared
210 .agent_sessions
211 .map(DeclaredSessions::resolve)
212 .transpose()
213 .map_err(invalid)?
214 .unwrap_or_default();
215 let signoff_idle_grace = declared
216 .signoff_idle_grace_seconds
217 .map(grace)
218 .transpose()
219 .map_err(invalid)?
220 .unwrap_or(SIGNOFF_IDLE_GRACE);
221 Ok(Self {
222 backends: declared.backends,
223 agent_sessions,
224 signoff_idle_grace,
225 })
226 }
227
228 pub fn set_signoff_idle_grace(state: &Path, grace: Duration) -> Result<Self, String> {
232 if grace > MAX_SIGNOFF_IDLE_GRACE || grace.subsec_nanos() != 0 {
233 return Err(format!(
234 "The sign-off idle grace must be whole seconds, at most {}.",
235 MAX_SIGNOFF_IDLE_GRACE.as_secs()
236 ));
237 }
238 Self::read(state)?;
239 let path = state.join(FILE);
240 let invalid = |message: String| format!("{}: {message}", crate::platform::path_text(&path));
241 let mut declared = match std::fs::read_to_string(&path) {
242 Ok(text) => serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&text)
243 .map_err(|e| invalid(e.to_string()))?,
244 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Default::default(),
245 Err(error) => return Err(invalid(error.to_string())),
246 };
247 declared.insert(GRACE_FIELD.into(), grace.as_secs().into());
248 crate::platform::create_private_dir_all(state).map_err(|e| invalid(e.to_string()))?;
249 let mut file =
250 tempfile::NamedTempFile::new_in(state).map_err(|e| invalid(e.to_string()))?;
251 serde_json::to_writer_pretty(&mut file, &declared).map_err(|e| invalid(e.to_string()))?;
252 file.write_all(b"\n").map_err(|e| invalid(e.to_string()))?;
253 file.persist(&path)
254 .map_err(|e| invalid(e.error.to_string()))?;
255 Self::read(state)
256 }
257
258 pub fn limit(&self, backend: &Backend) -> Option<u32> {
260 self.backends.get(backend).copied()
261 }
262
263 pub fn backends(&self) -> &BTreeMap<Backend, u32> {
264 &self.backends
265 }
266
267 pub fn agent_sessions(&self) -> AgentSessions {
268 self.agent_sessions
269 }
270
271 pub fn signoff_idle_grace(&self) -> Duration {
273 self.signoff_idle_grace
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use std::fs;
281
282 fn read(text: &str) -> Result<Limits, String> {
283 let directory = crate::test_os::tempdir();
284 fs::write(directory.path().join(FILE), text).unwrap();
285 Limits::read(directory.path())
286 }
287
288 #[test]
289 fn agent_sessions_default_and_validate() {
290 assert_eq!(
291 read("{}").unwrap().agent_sessions(),
292 AgentSessions {
293 enabled: true,
294 max_bytes: 1_073_741_824,
295 target_bytes: 805_306_368,
296 }
297 );
298 assert_eq!(
299 read(r#"{"agentSessions":{"enabled":false}}"#)
300 .unwrap()
301 .agent_sessions(),
302 AgentSessions {
303 enabled: false,
304 max_bytes: 1_073_741_824,
305 target_bytes: 805_306_368,
306 }
307 );
308 assert_eq!(
309 read(r#"{"agentSessions":{"maxBytes":4000,"targetBytes":1000}}"#)
310 .unwrap()
311 .agent_sessions(),
312 AgentSessions {
313 enabled: true,
314 max_bytes: 4000,
315 target_bytes: 1000
316 }
317 );
318 assert_eq!(
320 read(r#"{"agentSessions":{"maxBytes":4000}}"#)
321 .unwrap()
322 .agent_sessions()
323 .target_bytes,
324 3000
325 );
326 for (max_bytes, target_bytes) in [(1, 0), (3, 0), (5, 3), (7, 3), (8, 6), (11, 6)] {
328 assert_eq!(
329 read(&format!(
330 r#"{{"agentSessions":{{"maxBytes":{max_bytes}}}}}"#
331 ))
332 .unwrap()
333 .agent_sessions()
334 .target_bytes,
335 target_bytes
336 );
337 }
338 assert_eq!(
339 read(r#"{"agentSessions":{"maxBytes":18446744073709551615}}"#)
340 .unwrap()
341 .agent_sessions()
342 .target_bytes,
343 805306368
344 );
345 for (text, error) in [
346 (
347 r#"{"agentSessions":{"maxBytes":100,"targetBytes":100}}"#,
348 "agentSessions.targetBytes (100) must be lower than maxBytes (100).",
349 ),
350 (
351 r#"{"agentSessions":{"targetBytes":2147483648}}"#,
352 "must be lower than maxBytes (1073741824)",
353 ),
354 (
355 r#"{"agentSessions":{"maxBytes":0}}"#,
356 "agentSessions.maxBytes must be at least 1.",
357 ),
358 (r#"{"agentSessions":{"maxbytes":10}}"#, "unknown field"),
359 (r#"{"agentSessions":{"maxBytes":-1}}"#, "invalid value"),
360 (r#"{"agentSessions":{"enabled":"no"}}"#, "invalid type"),
361 (r#"{"agentSessions":true}"#, "invalid type"),
362 ] {
363 let message = read(text).unwrap_err();
364 assert!(message.contains(error), "{text}: {message}");
365 assert!(message.contains(FILE), "{message}");
366 }
367 }
368
369 #[test]
370 fn signoff_idle_grace_defaults_validates_and_saves_beside_other_limits() {
371 assert_eq!(
372 read("{}").unwrap().signoff_idle_grace(),
373 Duration::from_secs(120)
374 );
375 assert_eq!(
376 read(r#"{"signoffIdleGraceSeconds":0}"#)
377 .unwrap()
378 .signoff_idle_grace(),
379 Duration::ZERO
380 );
381 for (text, error) in [
382 (
383 r#"{"signoffIdleGraceSeconds":86401}"#,
384 "must be at most 86400",
385 ),
386 (r#"{"signoffIdleGraceSeconds":-1}"#, "invalid value"),
387 (r#"{"signoffIdleGraceSeconds":"2m"}"#, "invalid type"),
388 ] {
389 let message = read(text).unwrap_err();
390 assert!(message.contains(error), "{text}: {message}");
391 }
392
393 let directory = crate::test_os::tempdir();
394 let state = directory.path().join("state");
395 let saved = Limits::set_signoff_idle_grace(&state, Duration::from_secs(300)).unwrap();
396 assert_eq!(saved.signoff_idle_grace(), Duration::from_secs(300));
397 fs::write(
398 state.join(FILE),
399 r#"{"backends":{"codex":4},"agentSessions":{"maxBytes":4000}}"#,
400 )
401 .unwrap();
402 let saved = Limits::set_signoff_idle_grace(&state, Duration::ZERO).unwrap();
403 assert_eq!(saved.signoff_idle_grace(), Duration::ZERO);
404 assert_eq!(saved.limit(&crate::config::Provider::Codex.into()), Some(4));
405 assert_eq!(saved.agent_sessions().max_bytes, 4000);
406 assert_eq!(saved.agent_sessions().target_bytes, 3000);
407 assert!(
408 Limits::set_signoff_idle_grace(&state, Duration::from_secs(86401))
409 .unwrap_err()
410 .contains("at most 86400")
411 );
412 fs::write(state.join(FILE), r#"{"unknown":1}"#).unwrap();
413 assert!(Limits::set_signoff_idle_grace(&state, Duration::ZERO).is_err());
414 assert_eq!(
415 fs::read_to_string(state.join(FILE)).unwrap(),
416 r#"{"unknown":1}"#
417 );
418 }
419}