1use std::collections::HashMap;
23use std::sync::Mutex;
24use std::time::{Duration, Instant};
25
26use serde::Serialize;
27
28use crate::backend::memory_dir_claims as claims;
29use crate::backend::storage::error::StoreError;
30use crate::backend::storage::filestore::FileStore;
31
32const LIST_TTL: Duration = Duration::from_secs(30 * 60);
33
34#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
35#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "NativeMemoryClaimedFolder")]
36pub(crate) struct ClaimedFolderView {
37 pub index: usize,
38 pub dir: Option<String>,
40 #[ts(type = "number")]
41 pub claimed_at_ms: i64,
42 pub others: usize,
44}
45
46#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
47#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "NativeMemoryClaimList")]
48pub(crate) struct ClaimList {
49 pub list_id: String,
50 pub agent_uid: String,
51 pub folders: Vec<ClaimedFolderView>,
52}
53
54#[derive(Debug, PartialEq)]
55pub(crate) enum ReleaseError {
56 UnknownList,
57 BadChoice,
58 Store(String),
59}
60
61impl From<StoreError> for ReleaseError {
62 fn from(e: StoreError) -> Self {
63 ReleaseError::Store(e.to_string())
64 }
65}
66
67struct Issued {
68 agent_uid: String,
69 zones: Vec<String>,
70 at: Instant,
71}
72
73fn issued() -> &'static Mutex<HashMap<String, Issued>> {
74 static ISSUED: std::sync::OnceLock<Mutex<HashMap<String, Issued>>> = std::sync::OnceLock::new();
75 ISSUED.get_or_init(|| Mutex::new(HashMap::new()))
76}
77
78pub(crate) fn list(fs: &FileStore, agent_uid: &str) -> Result<ClaimList, StoreError> {
80 let found = claims::claims_of(fs, agent_uid)?;
81 let folders = found
82 .iter()
83 .enumerate()
84 .map(|(index, c)| ClaimedFolderView { index, dir: c.dir.clone(), claimed_at_ms: c.claimed_at_ms, others: c.others })
85 .collect();
86 let list_id = uuid::Uuid::new_v4().simple().to_string();
87 let mut map = issued().lock().unwrap_or_else(|e| e.into_inner());
88 map.retain(|_, i| i.at.elapsed() < LIST_TTL);
89 map.insert(
90 list_id.clone(),
91 Issued { agent_uid: agent_uid.to_string(), zones: found.into_iter().map(|c| c.zone).collect(), at: Instant::now() },
92 );
93 Ok(ClaimList { list_id, agent_uid: agent_uid.to_string(), folders })
94}
95
96pub(crate) fn release(fs: &FileStore, agent_uid: &str, list_id: &str, index: usize) -> Result<bool, ReleaseError> {
99 let zone = {
100 let map = issued().lock().unwrap_or_else(|e| e.into_inner());
101 let list = map
102 .get(list_id)
103 .filter(|l| l.agent_uid == agent_uid && l.at.elapsed() < LIST_TTL)
104 .ok_or(ReleaseError::UnknownList)?;
105 list.zones.get(index).cloned().ok_or(ReleaseError::BadChoice)?
106 };
107 Ok(claims::release(fs, agent_uid, &zone)?)
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113 use crate::backend::storage::store::Store;
114
115 fn claim(fs: &FileStore, store: &Store, uid: &str, dir: &std::path::Path) {
118 if store.agent_def_get(uid).ok().flatten().is_none() {
119 let mut def = crate::backend::storage::agents::test_agent_def(uid, uid, "claude", "agent", 1, "");
120 def.working_directory = format!("/work/{uid}");
121 store.agent_def_insert(&mut def).unwrap();
122 }
123 claims::check_and_claim(fs, store, uid, dir, "/work/x", Instant::now() + Duration::from_secs(10)).unwrap();
124 }
125
126 #[test]
127 fn an_agents_claims_are_listed_with_their_folders_and_released_by_list_index() {
128 let _g = crate::test_support::ISOLATED_AUTH_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
129 let fs = FileStore::open_in_memory().unwrap();
130 let store = Store::open_in_memory().unwrap();
131 let (a, b) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
132 claim(&fs, &store, "agent-rel", a.path());
133 claim(&fs, &store, "agent-rel", b.path());
134 claim(&fs, &store, "agent-other", b.path());
135 let list = list(&fs, "agent-rel").unwrap();
136 assert_eq!(list.folders.len(), 2);
137 let b_dir = claims::dir_id(b.path());
138 let b_entry = list.folders.iter().find(|f| f.dir.as_deref() == Some(b_dir.as_str())).unwrap();
139 assert_eq!(b_entry.others, 1);
140
141 assert_eq!(release(&fs, "agent-rel", &list.list_id, b_entry.index), Ok(true));
142 let after = claims::claims_of(&fs, "agent-rel").unwrap();
143 assert_eq!(after.len(), 1);
144 assert_ne!(after[0].dir.as_deref(), Some(b_dir.as_str()));
145 assert!(claims::held_exclusively(&fs, "agent-other", b.path()).unwrap());
147 }
148
149 #[test]
150 fn only_the_agents_own_list_releases_its_claims() {
151 let _g = crate::test_support::ISOLATED_AUTH_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
152 let fs = FileStore::open_in_memory().unwrap();
153 let store = Store::open_in_memory().unwrap();
154 let a = tempfile::tempdir().unwrap();
155 claim(&fs, &store, "agent-rel", a.path());
156 let list = list(&fs, "agent-rel").unwrap();
157 assert_eq!(release(&fs, "agent-else", &list.list_id, 0), Err(ReleaseError::UnknownList));
158 assert_eq!(release(&fs, "agent-rel", "no-such-list", 0), Err(ReleaseError::UnknownList));
159 assert_eq!(release(&fs, "agent-rel", &list.list_id, 9), Err(ReleaseError::BadChoice));
160 assert_eq!(claims::claims_of(&fs, "agent-rel").unwrap().len(), 1);
161 }
162}