1use crate::backend::storage::work_claims::WorkClaim;
11
12use super::matching::{mentions, sort_results, words, MatchKind, Target, WhoMatch, WhoResult};
13use super::overlap::EditedFile;
14use super::paths::path_matches;
15use super::WorkFacts;
16
17pub const DEFAULT_TTL_MINUTES: u64 = 120;
19pub const MIN_TTL_MINUTES: u64 = 5;
21pub const MAX_TTL_MINUTES: u64 = 24 * 60;
22
23pub fn ttl_ms(minutes: Option<u64>) -> i64 {
25 let m = minutes.unwrap_or(DEFAULT_TTL_MINUTES).clamp(MIN_TTL_MINUTES, MAX_TTL_MINUTES);
26 (m * 60_000) as i64
27}
28
29fn paths_overlap(a: &str, b: &str) -> bool {
31 path_matches(a, b) || path_matches(b, a)
32}
33
34pub fn is_own(c: &WorkClaim, agent: &str, uid: &str) -> bool {
36 if !c.agent_uid.is_empty() && !uid.is_empty() {
37 return c.agent_uid == uid;
38 }
39 !agent.is_empty() && c.agent.eq_ignore_ascii_case(agent)
40}
41
42pub fn claim_matches(t: &Target, c: &WorkClaim) -> bool {
46 let same_repo = t.repo.is_some() && c.repo == t.repo;
47 if let (Some(p), Some(cp)) = (&t.path, &c.path) {
48 if same_repo && paths_overlap(cp, p) {
49 return true;
50 }
51 }
52 if let (Some(p), Some(cp)) = (&t.absolute_path, &c.absolute_path) {
53 if paths_overlap(cp, p) {
54 return true;
55 }
56 }
57 let repo_only = t.path.is_none() && t.absolute_path.is_none() && t.branch.is_none() && t.query.is_none();
58 if repo_only && same_repo {
59 return true;
60 }
61 if let (Some(b), Some(cb)) = (&t.branch, &c.branch) {
62 if b == cb && (t.repo.is_none() || c.repo.is_none() || same_repo) {
63 return true;
64 }
65 }
66 if let Some(q) = &t.query {
67 let text = [c.topic.as_deref(), Some(c.note.as_str()), c.path.as_deref(), c.absolute_path.as_deref()]
68 .into_iter()
69 .flatten()
70 .collect::<Vec<_>>()
71 .join(" ");
72 if mentions(&words(q), &text) {
73 return true;
74 }
75 }
76 false
77}
78
79pub fn describe(c: &WorkClaim) -> String {
82 let whole_repo = c.path.as_deref() == Some("");
83 let mut parts: Vec<String> = Vec::new();
84 if whole_repo {
85 parts.push(format!("all of {}", c.repo.as_deref().unwrap_or("the repository")));
86 } else if let Some(p) = c.path.as_ref().or(c.absolute_path.as_ref()) {
87 parts.push(p.clone());
88 }
89 if let Some(b) = &c.branch {
90 parts.push(format!("branch {b}"));
91 }
92 if let Some(t) = &c.topic {
93 parts.push(format!("\"{t}\""));
94 }
95 let in_repo = !whole_repo && (c.path.is_some() || c.branch.is_some());
98 match &c.repo {
99 Some(r) if parts.is_empty() => r.clone(),
100 Some(r) if in_repo => format!("{} in {r}", parts.join(", ")),
101 _ if parts.is_empty() => "(nothing named)".to_string(),
102 _ => parts.join(", "),
103 }
104}
105
106pub fn ago(now_ms: i64, ts_ms: i64) -> String {
108 match (now_ms - ts_ms).max(0) / 60_000 {
109 0 => "just now".to_string(),
110 1 => "1 minute ago".to_string(),
111 m if m < 120 => format!("{m} minutes ago"),
112 m => format!("{} hours ago", m / 60),
113 }
114}
115
116pub fn detail(c: &WorkClaim, now_ms: i64) -> String {
119 let note = if c.note.trim().is_empty() { String::new() } else { format!(": \"{}\"", c.note.trim()) };
120 let left = ((c.expires_at - now_ms).max(0) + 59_999) / 60_000;
121 format!("claimed {} {}{note} (expires in {left} minutes)", describe(c), ago(now_ms, c.created_at))
122}
123
124pub fn add_claim_matches(
130 results: &mut Vec<WhoResult>,
131 t: &Target,
132 claims: &[WorkClaim],
133 facts: &[WorkFacts],
134 now_ms: i64,
135) {
136 for c in claims.iter().filter(|c| claim_matches(t, c)) {
137 let same = |agent: &str, channel: &str| agent.eq_ignore_ascii_case(&c.agent) && channel == c.channel;
138 let idx = results
139 .iter()
140 .position(|r| same(&r.agent, &r.channel))
141 .or_else(|| results.iter().position(|r| r.agent.eq_ignore_ascii_case(&c.agent)));
142 let idx = match idx {
143 Some(i) => i,
144 None => {
145 let f = facts
146 .iter()
147 .find(|f| same(&f.agent, &f.channel))
148 .or_else(|| facts.iter().find(|f| f.agent.eq_ignore_ascii_case(&c.agent)));
149 results.push(WhoResult {
150 agent: f.map(|f| f.agent.clone()).unwrap_or_else(|| c.agent.clone()),
151 channel: f.map(|f| f.channel.clone()).unwrap_or_else(|| c.channel.clone()),
152 status: f.map(|f| f.status.clone()).unwrap_or_else(|| "not running".to_string()),
153 goal: f.and_then(|f| f.goal.clone()),
154 repo: f.and_then(|f| f.repo.clone()).or_else(|| c.repo.clone()),
155 branch: f.and_then(|f| f.branch.clone()),
156 git_checked_ms: f.and_then(|f| f.git_checked_ms),
157 matches: Vec::new(),
158 });
159 results.len() - 1
160 }
161 };
162 let r = &mut results[idx];
163 r.matches.push(WhoMatch {
164 kind: MatchKind::Claim,
165 detail: detail(c, now_ms),
166 since_ms: u64::try_from(c.created_at).ok(),
167 });
168 r.matches.sort_by_key(|m| m.kind);
169 }
170 sort_results(results);
171}
172
173pub fn covering<'a>(file: &EditedFile, me_agent: &str, claims: &'a [WorkClaim]) -> Vec<&'a WorkClaim> {
177 claims
178 .iter()
179 .filter(|c| !c.agent.eq_ignore_ascii_case(me_agent))
180 .filter(|c| c.repo.as_deref() == Some(file.repo_key.as_str()))
181 .filter(|c| c.path.as_deref().is_some_and(|p| path_matches(&file.rel, p)))
182 .collect()
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 const NOW: i64 = 10 * 60 * 60 * 1000;
190 const MIN: i64 = 60_000;
191
192 fn claim(agent: &str) -> WorkClaim {
193 WorkClaim {
194 id: format!("c-{agent}"),
195 agent: agent.into(),
196 channel: "stable".into(),
197 repo: Some("o/r".into()),
198 note: "presence work".into(),
199 created_at: NOW - 40 * MIN,
200 expires_at: NOW + 80 * MIN,
201 ..Default::default()
202 }
203 }
204
205 fn on_path(agent: &str, path: &str) -> WorkClaim {
206 WorkClaim { path: Some(path.into()), ..claim(agent) }
207 }
208
209 fn target(repo: Option<&str>, path: Option<&str>, branch: Option<&str>, query: Option<&str>) -> Target {
210 Target {
211 repo: repo.map(Into::into),
212 path: path.map(Into::into),
213 branch: branch.map(Into::into),
214 query: query.map(Into::into),
215 ..Default::default()
216 }
217 }
218
219 #[test]
220 fn a_claimed_folder_and_a_file_in_it_match_either_way_round() {
221 let folder = on_path("Agent5", "crates/srv/src/muxbus");
222 assert!(claim_matches(&target(Some("o/r"), Some("crates/srv/src/muxbus/presence.rs"), None, None), &folder));
223 let file = on_path("Agent5", "crates/srv/src/muxbus/presence.rs");
224 assert!(claim_matches(&target(Some("o/r"), Some("crates/srv"), None, None), &file));
225 assert!(!claim_matches(&target(Some("o/r"), Some("crates/mcp"), None, None), &folder));
226 assert!(!claim_matches(&target(Some("o/other"), Some("crates/srv"), None, None), &folder), "another repository");
227 let whole = on_path("Agent5", "");
228 assert!(claim_matches(&target(Some("o/r"), Some("any/file.rs"), None, None), &whole));
229 }
230
231 #[test]
232 fn branch_repository_and_topic_questions_match_their_claims() {
233 let branch = WorkClaim { branch: Some("agent5/x".into()), ..claim("Agent5") };
234 assert!(claim_matches(&target(Some("o/r"), None, Some("agent5/x"), None), &branch));
235 assert!(claim_matches(&target(None, None, Some("agent5/x"), None), &branch));
236 assert!(!claim_matches(&target(Some("o/r"), None, Some("main"), None), &branch));
237
238 assert!(claim_matches(&target(Some("o/r"), None, None, None), &claim("Agent5")), "the repository as a whole");
239
240 let topic = WorkClaim { topic: Some("MuxBus allowlist".into()), repo: None, ..claim("Agent5") };
241 assert!(claim_matches(&target(None, None, None, Some("allowlist muxbus")), &topic));
242 assert!(claim_matches(&target(None, None, None, Some("presence")), &topic), "the note counts too");
243 assert!(!claim_matches(&target(None, None, None, Some("allowlist cognito")), &topic));
244 }
245
246 #[test]
247 fn a_claim_reads_as_what_when_and_why() {
248 assert_eq!(
249 detail(&on_path("Agent5", "crates/srv/src/muxbus"), NOW),
250 "claimed crates/srv/src/muxbus in o/r 40 minutes ago: \"presence work\" (expires in 80 minutes)"
251 );
252 let branch = WorkClaim { branch: Some("agent5/x".into()), note: String::new(), ..claim("Agent5") };
253 assert_eq!(detail(&branch, NOW), "claimed branch agent5/x in o/r 40 minutes ago (expires in 80 minutes)");
254 let topic = WorkClaim { topic: Some("presence".into()), repo: None, ..claim("Agent5") };
255 assert!(detail(&topic, NOW).starts_with("claimed \"presence\" 40 minutes ago"));
256 assert_eq!(describe(&on_path("Agent5", "")), "all of o/r");
257 assert_eq!(describe(&claim("Agent5")), "o/r");
258 assert_eq!(ago(NOW, NOW - 3 * 60 * MIN), "3 hours ago");
259 }
260
261 #[test]
262 fn the_lifetime_defaults_to_two_hours_within_five_minutes_and_a_day() {
263 assert_eq!(ttl_ms(None), 120 * MIN);
264 assert_eq!(ttl_ms(Some(1)), 5 * MIN);
265 assert_eq!(ttl_ms(Some(10_000)), 24 * 60 * MIN);
266 assert_eq!(ttl_ms(Some(45)), 45 * MIN);
267 }
268
269 #[test]
270 fn own_claims_are_known_by_uid_else_by_name() {
271 let mine = WorkClaim { agent_uid: "u1".into(), ..claim("AgentY") };
272 assert!(is_own(&mine, "agenty", ""));
273 assert!(is_own(&mine, "someone", "u1"));
274 assert!(!is_own(&mine, "AgentY", "u2"), "same name, another UID");
275 assert!(!is_own(&claim("Agent5"), "AgentY", "u1"));
276 }
277
278 #[test]
279 fn claims_join_the_claimers_result_or_add_one() {
280 let facts = vec![WorkFacts {
281 agent: "Agent4".into(),
282 channel: "stable".into(),
283 status: "idle".into(),
284 branch: Some("agent4/web".into()),
285 ..Default::default()
286 }];
287 let mut results = vec![WhoResult {
288 agent: "Agent5".into(),
289 channel: "stable".into(),
290 status: "busy".into(),
291 goal: None,
292 repo: Some("o/r".into()),
293 branch: None,
294 git_checked_ms: None,
295 matches: vec![WhoMatch { kind: MatchKind::Edited, detail: "recently edited".into(), since_ms: None }],
296 }];
297 let t = target(Some("o/r"), Some("crates/srv/src/muxbus/a.rs"), None, None);
298 let claims = vec![
299 on_path("Agent5", "crates/srv/src/muxbus"),
300 on_path("Agent4", "crates/srv"),
301 on_path("Gone", "crates"),
302 on_path("Elsewhere", "frontend"),
303 ];
304 add_claim_matches(&mut results, &t, &claims, &facts, NOW);
305 let names: Vec<&str> = results.iter().map(|r| r.agent.as_str()).collect();
306 assert_eq!(names, vec!["Agent4", "Agent5", "Gone"], "claims first, then by name");
307 let five = results.iter().find(|r| r.agent == "Agent5").unwrap();
308 assert_eq!(five.matches.iter().map(|m| m.kind).collect::<Vec<_>>(), vec![MatchKind::Claim, MatchKind::Edited]);
309 let four = results.iter().find(|r| r.agent == "Agent4").unwrap();
310 assert_eq!((four.status.as_str(), four.branch.as_deref()), ("idle", Some("agent4/web")));
311 assert_eq!(results.iter().find(|r| r.agent == "Gone").unwrap().status, "not running");
312 }
313
314 #[test]
315 fn an_edited_file_is_covered_by_other_agents_claims_on_it_or_a_folder_above_it() {
316 let file = EditedFile { repo_key: "o/r".into(), repo: "o/r".into(), rel: "crates/srv/src/muxbus/a.rs".into() };
317 let claims = vec![
318 on_path("Agent5", "crates/srv/src/muxbus"),
319 on_path("AgentY", "crates/srv"),
320 on_path("Agent4", "frontend"),
321 WorkClaim { topic: Some("muxbus".into()), ..claim("Agent2") },
322 WorkClaim { repo: Some("o/other".into()), ..on_path("Agent3", "crates") },
323 ];
324 let got: Vec<&str> = covering(&file, "agenty", &claims).iter().map(|c| c.agent.as_str()).collect();
325 assert_eq!(got, vec!["Agent5"]);
326 }
327}