1use std::collections::BTreeMap;
8use std::sync::Mutex;
9
10use serde::{Deserialize, Serialize};
11
12pub const AGENT_LEASE_SCHEMA_VERSION: u16 = 1;
13const DEFAULT_MAX_LEASES: usize = 1_024;
14const MAX_LEASE_DURATION_MS: u64 = 60 * 60 * 1_000;
15
16#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum AgentLeaseResourceKindV1 {
19 Path,
20 Symbol,
21}
22
23#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub struct AgentLeaseRequestV1 {
26 pub schema_version: u16,
27 pub lease_request_ref: String,
28 pub resource_kind: AgentLeaseResourceKindV1,
29 pub resource_ref: String,
30 pub owner_agent_id: String,
31 pub duration_ms: u64,
32}
33
34impl AgentLeaseRequestV1 {
35 pub fn validate(&self) -> Result<(), AgentLeaseError> {
36 if self.schema_version != AGENT_LEASE_SCHEMA_VERSION {
37 return Err(AgentLeaseError::UnsupportedVersion(self.schema_version));
38 }
39 opaque_ref("lease_request_ref", &self.lease_request_ref)?;
40 opaque_ref("resource_ref", &self.resource_ref)?;
41 agent_id(&self.owner_agent_id)?;
42 if self.duration_ms == 0 || self.duration_ms > MAX_LEASE_DURATION_MS {
43 return Err(AgentLeaseError::Invalid(format!(
44 "duration_ms must be between 1 and {MAX_LEASE_DURATION_MS}"
45 )));
46 }
47 Ok(())
48 }
49}
50
51#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct AgentLeaseV1 {
54 pub schema_version: u16,
55 pub lease_ref: String,
56 pub request: AgentLeaseRequestV1,
57 pub expires_at_epoch_ms: u64,
58}
59
60impl AgentLeaseV1 {
61 pub fn is_active_at(&self, now_epoch_ms: u64) -> bool {
62 now_epoch_ms < self.expires_at_epoch_ms
63 }
64}
65
66#[derive(Clone, Debug, Eq, PartialEq)]
67pub enum AgentLeaseAcquireV1 {
68 Granted(AgentLeaseV1),
69 HeldBy {
70 owner_agent_id: String,
71 lease_ref: String,
72 expires_at_epoch_ms: u64,
73 },
74}
75
76pub struct AgentLeaseRegistryV1 {
78 leases: BTreeMap<(AgentLeaseResourceKindV1, String), AgentLeaseV1>,
79 max_leases: usize,
80}
81
82impl Default for AgentLeaseRegistryV1 {
83 fn default() -> Self {
84 Self::new(DEFAULT_MAX_LEASES)
85 }
86}
87
88impl AgentLeaseRegistryV1 {
89 #[must_use]
90 pub fn new(max_leases: usize) -> Self {
91 Self {
92 leases: BTreeMap::new(),
93 max_leases: max_leases.max(1),
94 }
95 }
96
97 pub fn acquire(
98 &mut self,
99 request: AgentLeaseRequestV1,
100 now_epoch_ms: u64,
101 ) -> Result<AgentLeaseAcquireV1, AgentLeaseError> {
102 request.validate()?;
103 self.remove_expired(now_epoch_ms);
104 let key = (request.resource_kind, request.resource_ref.clone());
105 if let Some(existing) = self.leases.get(&key) {
106 if existing.request.owner_agent_id == request.owner_agent_id
107 && existing.request.lease_request_ref == request.lease_request_ref
108 {
109 return Ok(AgentLeaseAcquireV1::Granted(existing.clone()));
110 }
111 return Ok(AgentLeaseAcquireV1::HeldBy {
112 owner_agent_id: existing.request.owner_agent_id.clone(),
113 lease_ref: existing.lease_ref.clone(),
114 expires_at_epoch_ms: existing.expires_at_epoch_ms,
115 });
116 }
117 if self.leases.len() >= self.max_leases {
118 return Err(AgentLeaseError::CapacityExceeded(self.max_leases));
119 }
120 let expires_at_epoch_ms = now_epoch_ms.saturating_add(request.duration_ms);
121 let lease_ref = compute_lease_ref(&request, expires_at_epoch_ms)?;
122 let lease = AgentLeaseV1 {
123 schema_version: AGENT_LEASE_SCHEMA_VERSION,
124 lease_ref,
125 request,
126 expires_at_epoch_ms,
127 };
128 self.leases.insert(key, lease.clone());
129 Ok(AgentLeaseAcquireV1::Granted(lease))
130 }
131
132 pub fn release(
133 &mut self,
134 resource_kind: AgentLeaseResourceKindV1,
135 resource_ref: &str,
136 owner_agent_id: &str,
137 lease_ref: &str,
138 now_epoch_ms: u64,
139 ) -> Result<bool, AgentLeaseError> {
140 opaque_ref("resource_ref", resource_ref)?;
141 agent_id(owner_agent_id)?;
142 self.remove_expired(now_epoch_ms);
143 let key = (resource_kind, resource_ref.to_string());
144 let Some(existing) = self.leases.get(&key) else {
145 return Ok(false);
146 };
147 if existing.request.owner_agent_id != owner_agent_id || existing.lease_ref != lease_ref {
148 return Err(AgentLeaseError::NotOwner);
149 }
150 self.leases.remove(&key);
151 Ok(true)
152 }
153
154 #[must_use]
155 pub fn active_count(&self, now_epoch_ms: u64) -> usize {
156 self.leases
157 .values()
158 .filter(|l| l.is_active_at(now_epoch_ms))
159 .count()
160 }
161
162 fn remove_expired(&mut self, now_epoch_ms: u64) {
163 self.leases.retain(|_, l| l.is_active_at(now_epoch_ms));
164 }
165}
166
167static GLOBAL_REGISTRY: std::sync::OnceLock<Mutex<AgentLeaseRegistryV1>> =
170 std::sync::OnceLock::new();
171
172fn global_registry() -> &'static Mutex<AgentLeaseRegistryV1> {
173 GLOBAL_REGISTRY.get_or_init(|| Mutex::new(AgentLeaseRegistryV1::default()))
174}
175
176fn now_epoch_ms() -> u64 {
177 std::time::SystemTime::now()
178 .duration_since(std::time::UNIX_EPOCH)
179 .unwrap_or_default()
180 .as_millis() as u64
181}
182
183pub fn acquire_local(request: AgentLeaseRequestV1) -> Result<AgentLeaseAcquireV1, AgentLeaseError> {
184 let mut reg = global_registry()
185 .lock()
186 .unwrap_or_else(std::sync::PoisonError::into_inner);
187 reg.acquire(request, now_epoch_ms())
188}
189
190pub fn release_local(
191 resource_kind: AgentLeaseResourceKindV1,
192 resource_ref: &str,
193 owner_agent_id: &str,
194 lease_ref: &str,
195) -> Result<bool, AgentLeaseError> {
196 let mut reg = global_registry()
197 .lock()
198 .unwrap_or_else(std::sync::PoisonError::into_inner);
199 reg.release(
200 resource_kind,
201 resource_ref,
202 owner_agent_id,
203 lease_ref,
204 now_epoch_ms(),
205 )
206}
207
208fn compute_lease_ref(
211 request: &AgentLeaseRequestV1,
212 expires_at_epoch_ms: u64,
213) -> Result<String, AgentLeaseError> {
214 let bytes = serde_json::to_vec(&(request, expires_at_epoch_ms))
215 .map_err(|e| AgentLeaseError::Serialize(e.to_string()))?;
216 Ok(format!("lease:{}", blake3::hash(&bytes).to_hex()))
217}
218
219fn opaque_ref(label: &str, value: &str) -> Result<(), AgentLeaseError> {
220 let (scheme, identifier) = value.split_once(':').ok_or_else(|| {
221 AgentLeaseError::Invalid(format!("{label} must use scheme:identifier form"))
222 })?;
223 let scheme_valid = !scheme.is_empty()
224 && scheme
225 .bytes()
226 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
227 let identifier_valid = !identifier.is_empty()
228 && value.len() <= 256
229 && identifier.bytes().all(|b| b.is_ascii_graphic());
230 (scheme_valid && identifier_valid)
231 .then_some(())
232 .ok_or_else(|| AgentLeaseError::Invalid(format!("invalid {label}")))
233}
234
235fn agent_id(value: &str) -> Result<(), AgentLeaseError> {
236 (!value.is_empty() && value.len() <= 256 && value.bytes().all(|b| b.is_ascii_graphic()))
237 .then_some(())
238 .ok_or_else(|| AgentLeaseError::Invalid("invalid owner_agent_id".into()))
239}
240
241#[derive(Debug, thiserror::Error)]
244pub enum AgentLeaseError {
245 #[error("unsupported schema version {0}")]
246 UnsupportedVersion(u16),
247 #[error("invalid lease: {0}")]
248 Invalid(String),
249 #[error("registry at capacity {0}")]
250 CapacityExceeded(usize),
251 #[error("not owner or mismatched lease_ref")]
252 NotOwner,
253 #[error("serialization failed: {0}")]
254 Serialize(String),
255}
256
257#[cfg(test)]
260mod tests {
261 use super::*;
262
263 fn request(owner: &str, ref_id: &str) -> AgentLeaseRequestV1 {
264 AgentLeaseRequestV1 {
265 schema_version: AGENT_LEASE_SCHEMA_VERSION,
266 lease_request_ref: ref_id.to_string(),
267 resource_kind: AgentLeaseResourceKindV1::Path,
268 resource_ref: "pathref:src-core-main".to_string(),
269 owner_agent_id: owner.to_string(),
270 duration_ms: 100,
271 }
272 }
273
274 #[test]
275 fn idempotent_grant_blocks_foreign_owner() {
276 let mut reg = AgentLeaseRegistryV1::default();
277 let AgentLeaseAcquireV1::Granted(granted) =
278 reg.acquire(request("agent-a", "request:a"), 10).unwrap()
279 else {
280 panic!("expected grant")
281 };
282 assert_eq!(
283 reg.acquire(request("agent-a", "request:a"), 20).unwrap(),
284 AgentLeaseAcquireV1::Granted(granted)
285 );
286 assert!(matches!(
287 reg.acquire(request("agent-b", "request:b"), 20),
288 Ok(AgentLeaseAcquireV1::HeldBy { owner_agent_id, .. }) if owner_agent_id == "agent-a"
289 ));
290 }
291
292 #[test]
293 fn expiry_frees_resource() {
294 let mut reg = AgentLeaseRegistryV1::default();
295 let _ = reg.acquire(request("agent-a", "request:a"), 10).unwrap();
296 assert!(matches!(
297 reg.acquire(request("agent-b", "request:b"), 111),
298 Ok(AgentLeaseAcquireV1::Granted(_))
299 ));
300 }
301
302 #[test]
303 fn release_requires_owner() {
304 let mut reg = AgentLeaseRegistryV1::default();
305 let AgentLeaseAcquireV1::Granted(granted) =
306 reg.acquire(request("agent-a", "request:a"), 10).unwrap()
307 else {
308 panic!("expected grant")
309 };
310 assert!(
311 reg.release(
312 AgentLeaseResourceKindV1::Path,
313 "pathref:src-core-main",
314 "agent-b",
315 &granted.lease_ref,
316 20
317 )
318 .is_err()
319 );
320 assert!(
321 reg.release(
322 AgentLeaseResourceKindV1::Path,
323 "pathref:src-core-main",
324 "agent-a",
325 &granted.lease_ref,
326 20
327 )
328 .unwrap()
329 );
330 }
331
332 #[test]
333 fn capacity_enforced() {
334 let mut reg = AgentLeaseRegistryV1::new(1);
335 let _ = reg.acquire(request("agent-a", "request:a"), 1).unwrap();
336 let second = AgentLeaseRequestV1 {
337 resource_ref: "symbolref:main".to_string(),
338 resource_kind: AgentLeaseResourceKindV1::Symbol,
339 ..request("agent-b", "request:b")
340 };
341 assert!(matches!(
342 reg.acquire(second, 1),
343 Err(AgentLeaseError::CapacityExceeded(1))
344 ));
345 }
346}