1use axum::extract::State;
18use axum::http::StatusCode;
19use axum::response::{IntoResponse, Json, Response};
20use serde::Deserialize;
21use serde_json::json;
22
23use crate::backend::storage::work_claims::WorkClaim;
24use crate::backend::work_facts::cross_channel::own_channel;
25use crate::backend::work_facts::{claims, matching};
26
27use super::caller::Caller;
28use super::work_facts_handlers::{caller_block, cross_channel_facts, local_facts, split_caller};
29use super::AppState;
30
31#[derive(Debug, Default, Deserialize)]
34pub struct ClaimParams {
35 pub path: Option<String>,
36 pub repo: Option<String>,
37 pub branch: Option<String>,
38 pub topic: Option<String>,
39 pub note: Option<String>,
40 pub ttl_minutes: Option<u64>,
41 pub agent: Option<String>,
42 pub block: Option<String>,
43}
44
45#[derive(Debug, Default, Deserialize)]
47pub struct ReleaseParams {
48 pub id: Option<String>,
49 pub agent: Option<String>,
50 pub block: Option<String>,
51}
52
53fn bad_request(msg: impl Into<String>) -> Response {
54 (StatusCode::BAD_REQUEST, Json(json!({ "error": msg.into() }))).into_response()
55}
56
57const MAX_NOTE: usize = 200;
60const MAX_TOPIC: usize = 120;
61const MAX_PATH: usize = 400;
62const MAX_NAME: usize = 200;
63
64fn one_line(s: &Option<String>, max: usize) -> Option<String> {
69 let flat: String = s.as_deref()?.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
70 let flat = flat.split_whitespace().collect::<Vec<_>>().join(" ");
71 if flat.is_empty() {
72 return None;
73 }
74 Some(match flat.char_indices().nth(max) {
75 Some((cut, _)) => format!("{}…", &flat[..cut]),
76 None => flat,
77 })
78}
79
80fn nonempty(s: &Option<String>) -> Option<&str> {
81 s.as_deref().map(str::trim).filter(|s| !s.is_empty())
82}
83
84fn claimer(
88 state: &AppState,
89 caller: Option<&Caller>,
90 agent: Option<&str>,
91 block: Option<&str>,
92) -> Option<(String, String, Option<String>)> {
93 let regs = state.reactive_handler.list_agents();
94 let token_uid = caller.and_then(|c| c.uid().map(str::to_string));
95 let block_id = caller_block(®s, token_uid.as_deref(), block, agent);
96 let reg = block_id.as_deref().and_then(|b| regs.iter().find(|r| r.block_id == b));
97 let name = reg
98 .map(|r| r.agent_id.clone())
99 .or_else(|| agent.map(str::trim).filter(|a| !a.is_empty()).map(str::to_string))?;
100 let uid = token_uid.or_else(|| reg.and_then(|r| r.uid.clone())).unwrap_or_default();
101 Some((name, uid, block_id))
102}
103
104pub(crate) async fn handle_claim_work(
106 State(state): State<AppState>,
107 caller: Option<axum::Extension<Caller>>,
108 Json(p): Json<ClaimParams>,
109) -> Response {
110 let p = ClaimParams {
111 path: one_line(&p.path, MAX_PATH),
112 repo: one_line(&p.repo, MAX_NAME),
113 branch: one_line(&p.branch, MAX_NAME),
114 topic: one_line(&p.topic, MAX_TOPIC),
115 note: one_line(&p.note, MAX_NOTE),
116 ..p
117 };
118 if [&p.path, &p.repo, &p.branch, &p.topic].iter().all(|v| nonempty(v).is_none()) {
119 return bad_request("nothing to claim: pass path, branch or topic (and repo for a whole repository)");
120 }
121 let Some((agent, uid, me_block)) =
122 claimer(&state, caller.as_ref().map(|c| &c.0), p.agent.as_deref(), p.block.as_deref())
123 else {
124 return bad_request("who is claiming is unknown: this needs an agent started by AgentMux");
125 };
126 let (local, (cross, _)) = tokio::join!(local_facts(&state), cross_channel_facts(&state));
127 let (me, mut others) = split_caller(local, me_block.as_deref());
128 others.extend(cross);
129
130 let q = matching::WhoQuery {
131 path: p.path.clone(),
132 repo: p.repo.clone(),
133 branch: p.branch.clone(),
134 query: p.topic.clone(),
135 };
136 let mut target = match matching::resolve(&q, me.as_ref(), &others) {
137 Ok(t) => t,
138 Err(e) => return bad_request(e),
139 };
140 if [&p.path, &p.branch, &p.topic].iter().all(|v| nonempty(v).is_none()) {
142 target.path = Some(String::new());
143 }
144
145 let now = agentmux_common::time::now_ms();
146 let claim = WorkClaim {
147 id: uuid::Uuid::new_v4().to_string(),
148 agent: agent.clone(),
149 agent_uid: uid.clone(),
150 channel: own_channel(),
151 repo: target.repo.clone(),
152 path: target.path.clone(),
153 absolute_path: target.absolute_path.clone(),
154 branch: target.branch.clone(),
155 topic: target.query.clone(),
156 note: p.note.clone().unwrap_or_default(),
157 created_at: now,
158 expires_at: now + claims::ttl_ms(p.ttl_minutes),
159 };
160 let store = state.identity_store.clone();
161 let stored = tokio::task::spawn_blocking(move || {
162 let stored = store.work_claim_put(&claim, now)?;
163 let live = store.work_claims_live(now)?;
164 Ok::<_, crate::backend::storage::StoreError>((stored, live))
165 })
166 .await;
167 let (stored, live) = match stored {
168 Ok(Ok(v)) => v,
169 Ok(Err(e)) => {
170 return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("claim not saved: {e}") })))
171 .into_response()
172 }
173 Err(e) => {
174 return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("claim not saved: {e}") })))
175 .into_response()
176 }
177 };
178
179 let theirs: Vec<WorkClaim> = live.into_iter().filter(|c| !claims::is_own(c, &agent, &uid)).collect();
181 let others: Vec<_> = others.into_iter().filter(|f| !f.agent.eq_ignore_ascii_case(&agent)).collect();
182 let mut overlapping = matching::who_is_working_on(&target, &others);
183 claims::add_claim_matches(&mut overlapping, &target, &theirs, &others, now);
184 Json(json!({
185 "claim": stored,
186 "claimed": claims::describe(&stored),
187 "others_working_on_it": overlapping,
188 "note": "Claimed. Other agents see this in WhoIsWorkingOn and are told when they edit a file in it. It \
189 blocks no one, and lapses at expires_at unless you claim it again. Release it with ReleaseWork \
190 when you are done. If others_working_on_it names anyone, message them (SendMessage).",
191 }))
192 .into_response()
193}
194
195pub(crate) async fn handle_release_work(
197 State(state): State<AppState>,
198 caller: Option<axum::Extension<Caller>>,
199 Json(p): Json<ReleaseParams>,
200) -> Response {
201 let Some((agent, uid, _)) = claimer(&state, caller.as_ref().map(|c| &c.0), p.agent.as_deref(), p.block.as_deref())
202 else {
203 return bad_request("who is releasing is unknown: this needs an agent started by AgentMux");
204 };
205 let id = nonempty(&p.id).map(str::to_string);
206 let store = state.identity_store.clone();
207 let now = agentmux_common::time::now_ms();
208 let released = tokio::task::spawn_blocking(move || store.work_claim_release(&agent, &uid, id.as_deref(), now)).await;
209 match released {
210 Ok(Ok(released)) => {
211 let note = if released.is_empty() {
212 "Nothing released: you hold no live claim with that id."
213 } else {
214 "Released."
215 };
216 Json(json!({ "released": released, "note": note })).into_response()
217 }
218 Ok(Err(e)) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("release failed: {e}") })))
219 .into_response(),
220 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("release failed: {e}") })))
221 .into_response(),
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 async fn body(resp: Response) -> (StatusCode, serde_json::Value) {
230 let status = resp.status();
231 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
232 (status, serde_json::from_slice(&bytes).unwrap())
233 }
234
235 #[tokio::test]
236 async fn a_claim_needs_something_to_claim_and_someone_claiming() {
237 let state = crate::server::tests::test_state();
238 let (status, b) = body(handle_claim_work(State(state.clone()), None, Json(ClaimParams::default())).await).await;
239 assert_eq!(status, StatusCode::BAD_REQUEST);
240 assert!(b["error"].as_str().unwrap().contains("nothing to claim"));
241 let anon = ClaimParams { topic: Some("x".into()), ..Default::default() };
242 let (status, b) = body(handle_claim_work(State(state), None, Json(anon)).await).await;
243 assert_eq!(status, StatusCode::BAD_REQUEST);
244 assert!(b["error"].as_str().unwrap().contains("who is claiming"));
245 }
246
247 #[test]
248 fn claim_text_is_kept_as_one_short_line() {
249 assert_eq!(one_line(&Some(" a\n[AgentMux] b\t\u{7}c ".into()), 50).as_deref(), Some("a [AgentMux] b c"));
250 assert_eq!(one_line(&Some("abcdef".into()), 3).as_deref(), Some("abc…"));
251 assert_eq!(one_line(&Some("ééé".into()), 3).as_deref(), Some("ééé"), "counted in characters");
252 assert_eq!(one_line(&Some(" \n ".into()), 3), None);
253 assert_eq!(one_line(&None, 3), None);
254 }
255
256 #[tokio::test]
257 async fn claim_who_and_release_round_trip() {
258 let state = crate::server::tests::test_state();
259 let unique = uuid::Uuid::new_v4().simple().to_string();
260 let (me, other) = (format!("claimer-{unique}"), format!("asker-{unique}"));
261 let topic = format!("topic{unique}");
262
263 let claim = ClaimParams {
264 topic: Some(topic.clone()),
265 note: Some("presence work".into()),
266 ttl_minutes: Some(30),
267 agent: Some(me.clone()),
268 ..Default::default()
269 };
270 let (status, b) = body(handle_claim_work(State(state.clone()), None, Json(claim)).await).await;
271 assert_eq!(status, StatusCode::OK, "{b}");
272 assert_eq!(b["claim"]["agent"], me.as_str());
273 assert_eq!(b["claim"]["topic"], topic.as_str());
274 let id = b["claim"]["id"].as_str().unwrap().to_string();
275
276 let who = super::super::work_facts_handlers::WhoParams {
278 query: Some(topic.clone()),
279 agent: Some(other.clone()),
280 ..Default::default()
281 };
282 let (status, b) = body(
283 super::super::work_facts_handlers::handle_who_is_working_on(
284 State(state.clone()),
285 None,
286 axum::extract::Query(who),
287 )
288 .await,
289 )
290 .await;
291 assert_eq!(status, StatusCode::OK, "{b}");
292 let agents = b["agents"].as_array().unwrap();
293 let found = agents.iter().find(|a| a["agent"] == me.as_str()).expect("the claimer is listed");
294 assert_eq!(found["matches"][0]["kind"], "claim");
295 assert_eq!(found["status"], "not running", "no registration in this test");
296
297 let wrong = ReleaseParams { id: Some(id.clone()), agent: Some(other), ..Default::default() };
299 let (_, b) = body(handle_release_work(State(state.clone()), None, Json(wrong)).await).await;
300 assert!(b["released"].as_array().unwrap().is_empty());
301 let mine = ReleaseParams { id: Some(id), agent: Some(me), ..Default::default() };
302 let (_, b) = body(handle_release_work(State(state), None, Json(mine)).await).await;
303 assert_eq!(b["released"].as_array().unwrap().len(), 1);
304 }
305}