1use axum::extract::{Path, Query, State};
25use axum::http::StatusCode;
26use axum::response::Json;
27use serde::Deserialize;
28use serde_json::{json, Value};
29use uuid::Uuid;
30
31use crate::backend::storage::work_queue::{ClaimFilter, HolderMatch, WorkItem};
32
33use super::AppState;
34
35const DEFAULT_LEASE_MS: i64 = 120_000;
40
41use agentmux_common::time::now_ms;
42
43pub(crate) fn publish_changed(state: &AppState) {
44 state.broker.publish_event::<crate::backend::mps_events::WorkQueueChanged>(&(), vec![], 0);
45}
46
47fn err(code: StatusCode, msg: impl Into<String>) -> (StatusCode, Json<Value>) {
48 (code, Json(json!({ "error": msg.into() })))
49}
50
51#[derive(Debug, Deserialize)]
52pub(super) struct EnqueueRequest {
53 pub title: String,
54 pub payload: String,
55 #[serde(default)]
56 pub kind: String,
57 #[serde(default)]
58 pub target_agent: String,
59 #[serde(default)]
63 pub target_agent_uid: String,
64 #[serde(default)]
65 pub target_group: String,
66 #[serde(default)]
67 pub priority: i64,
68 #[serde(default)]
69 pub created_by: String,
70 #[serde(default)]
71 pub max_attempts: Option<i64>,
72 #[serde(default)]
74 pub not_before: Option<i64>,
75}
76
77pub(super) async fn handle_work_enqueue(
78 State(state): State<AppState>,
79 caller: Option<axum::Extension<super::caller::Caller>>,
80 Json(req): Json<EnqueueRequest>,
81) -> (StatusCode, Json<Value>) {
82 super::actor::check_actor(
83 &state,
84 caller.as_deref(),
85 super::actor::ActorSite::WorkEnqueue,
86 Some(&req.created_by),
87 );
88 if req.title.trim().is_empty() {
89 return err(StatusCode::BAD_REQUEST, "title is required");
90 }
91 if req.payload.trim().is_empty() {
92 return err(StatusCode::BAD_REQUEST, "payload is required");
93 }
94 if !req.target_agent.is_empty() && !req.target_group.is_empty() {
98 return err(
99 StatusCode::BAD_REQUEST,
100 "specify target_agent or target_group, not both",
101 );
102 }
103
104 let target_agent_uid = req.target_agent_uid.trim().to_string();
113 if target_agent_uid.is_empty() && !req.target_agent.trim().is_empty() {
114 crate::backend::agent_resolve::record_uid_fallback("work_enqueue.uid_not_carried");
115 }
116
117 let now = now_ms();
118 let item = WorkItem {
119 id: Uuid::new_v4().to_string(),
120 title: req.title,
121 payload: req.payload,
122 kind: req.kind,
123 target_agent: req.target_agent,
124 target_group: req.target_group,
125 priority: req.priority,
126 state: String::new(), claimed_by: String::new(),
128 target_agent_uid,
129 claimed_by_uid: String::new(),
130 claim_expires: None,
131 attempts: 0,
132 max_attempts: req.max_attempts.unwrap_or(3),
133 created_by: req.created_by,
134 created_by_uid: super::caller::attributed_uid(caller.as_deref()),
137 created_at: now,
138 updated_at: now,
139 not_before: req.not_before,
140 result: String::new(),
141 };
142
143 match state.identity_store.work_queue_enqueue(&item) {
144 Ok(()) => {
145 publish_changed(&state);
146 (StatusCode::OK, Json(json!({ "id": item.id })))
147 }
148 Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, format!("enqueue failed: {e}")),
149 }
150}
151
152#[derive(Debug, Deserialize)]
153pub(super) struct ClaimRequest {
154 pub agent_id: String,
156 #[serde(default)]
164 pub agent_uid: String,
165 #[serde(default)]
169 pub block_id: String,
170 #[serde(default)]
171 pub kind: Option<String>,
172 #[serde(default)]
176 pub groups: Vec<String>,
177 #[serde(default)]
178 pub lease_ms: Option<i64>,
179}
180
181async fn claimer_uid(state: &AppState, req: &ClaimRequest) -> String {
185 let carried = req.agent_uid.trim();
186 if !carried.is_empty() {
187 return carried.to_string();
188 }
189 crate::backend::agent_resolve::record_uid_fallback("work_claim.uid_not_carried");
190 let block_id = req.block_id.trim().to_string();
191 if block_id.is_empty() {
192 return String::new();
193 }
194 let mstore = state.mstore.clone();
196 tokio::task::spawn_blocking(move || {
197 crate::backend::agent_resolve::uid_for_block(&mstore, &block_id)
198 })
199 .await
200 .ok()
201 .flatten()
202 .unwrap_or_default()
203}
204
205pub(super) async fn handle_work_claim(
206 State(state): State<AppState>,
207 caller: Option<axum::Extension<super::caller::Caller>>,
208 Json(req): Json<ClaimRequest>,
209) -> (StatusCode, Json<Value>) {
210 super::actor::check_actor(
211 &state,
212 caller.as_deref(),
213 super::actor::ActorSite::WorkClaim,
214 Some(&req.agent_id),
215 );
216 super::actor::check_carried_uid(caller.as_deref(), &req.agent_uid);
217 if req.agent_id.trim().is_empty() {
218 return err(StatusCode::BAD_REQUEST, "agent_id is required");
219 }
220 let now = now_ms();
221
222 if let Err(e) = state.identity_store.work_queue_reap(now) {
229 tracing::warn!(target: "workqueue", error = %e, "reap before claim failed");
233 }
234
235 let agent_uid = match caller.as_deref().and_then(super::caller::Caller::uid) {
249 Some(uid) => uid.to_string(),
250 None => claimer_uid(&state, &req).await,
251 };
252
253 let filter = ClaimFilter {
254 kind: req.kind.filter(|k| !k.is_empty()),
255 agent_id: req.agent_id.clone(),
256 agent_uid,
257 groups: req.groups.clone(),
258 };
259 let lease = req.lease_ms.filter(|&n| n > 0).unwrap_or(DEFAULT_LEASE_MS);
260
261 match state.identity_store.work_queue_claim(&filter, now, lease) {
262 Ok(Some(item)) => {
263 publish_changed(&state);
264 (
271 StatusCode::OK,
272 Json(json!({ "claimed": true, "attempt": item.attempts, "item": item })),
273 )
274 }
275 Ok(None) => (StatusCode::OK, Json(json!({ "claimed": false }))),
276 Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, format!("claim failed: {e}")),
277 }
278}
279
280#[derive(Debug, Deserialize)]
281pub(super) struct HolderRequest {
282 pub agent_id: String,
283 pub attempt: i64,
285 #[serde(default)]
286 pub result: String,
287 #[serde(default)]
288 pub lease_ms: Option<i64>,
289}
290
291fn holder_uid(caller: Option<&super::caller::Caller>) -> &str {
294 caller.and_then(super::caller::Caller::uid).unwrap_or("")
295}
296
297fn count_holder_match(matched: Option<HolderMatch>) {
300 if matched == Some(HolderMatch::Name) {
301 crate::backend::agent_resolve::record_uid_fallback("m4c.holder_by_name");
302 }
303}
304
305fn holder_result(ok: bool, state: &AppState) -> (StatusCode, Json<Value>) {
310 if ok {
311 publish_changed(state);
312 (StatusCode::OK, Json(json!({ "ok": true })))
313 } else {
314 (
315 StatusCode::CONFLICT,
316 Json(json!({
317 "ok": false,
318 "error": "not the current holder, or this claim has been superseded \
319 (expired and reclaimed) — re-claim before continuing"
320 })),
321 )
322 }
323}
324
325pub(super) async fn handle_work_heartbeat(
326 State(state): State<AppState>,
327 Path(id): Path<String>,
328 caller: Option<axum::Extension<super::caller::Caller>>,
329 Json(req): Json<HolderRequest>,
330) -> (StatusCode, Json<Value>) {
331 super::actor::check_actor(
332 &state,
333 caller.as_deref(),
334 super::actor::ActorSite::WorkHeartbeat,
335 Some(&req.agent_id),
336 );
337 let lease = req.lease_ms.filter(|&n| n > 0).unwrap_or(DEFAULT_LEASE_MS);
338 match state
339 .identity_store
340 .work_queue_heartbeat(&id, &req.agent_id, holder_uid(caller.as_deref()), req.attempt, now_ms(), lease)
341 {
342 Ok(matched) => {
343 count_holder_match(matched);
344 holder_result(matched.is_some(), &state)
345 }
346 Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, format!("heartbeat failed: {e}")),
347 }
348}
349
350pub(super) async fn handle_work_complete(
351 State(state): State<AppState>,
352 Path(id): Path<String>,
353 caller: Option<axum::Extension<super::caller::Caller>>,
354 Json(req): Json<HolderRequest>,
355) -> (StatusCode, Json<Value>) {
356 super::actor::check_actor(
357 &state,
358 caller.as_deref(),
359 super::actor::ActorSite::WorkComplete,
360 Some(&req.agent_id),
361 );
362 match state
363 .identity_store
364 .work_queue_complete(&id, &req.agent_id, holder_uid(caller.as_deref()), req.attempt, &req.result, now_ms())
365 {
366 Ok(matched) => {
367 count_holder_match(matched);
368 holder_result(matched.is_some(), &state)
369 }
370 Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, format!("complete failed: {e}")),
371 }
372}
373
374pub(super) async fn handle_work_release(
379 State(state): State<AppState>,
380 Path(id): Path<String>,
381 caller: Option<axum::Extension<super::caller::Caller>>,
382 Json(req): Json<HolderRequest>,
383) -> (StatusCode, Json<Value>) {
384 super::actor::check_actor(
385 &state,
386 caller.as_deref(),
387 super::actor::ActorSite::WorkRelease,
388 Some(&req.agent_id),
389 );
390 match state
391 .identity_store
392 .work_queue_release(&id, &req.agent_id, holder_uid(caller.as_deref()), req.attempt, &req.result, now_ms())
393 {
394 Ok(Some(matched)) => {
395 count_holder_match(Some(matched));
396 publish_changed(&state);
397 let resulting = state
401 .identity_store
402 .work_queue_get(&id)
403 .ok()
404 .flatten()
405 .map(|i| i.state)
406 .unwrap_or_default();
407 (StatusCode::OK, Json(json!({ "ok": true, "state": resulting })))
408 }
409 Ok(None) => holder_result(false, &state),
410 Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, format!("release failed: {e}")),
411 }
412}
413
414#[derive(Debug, Deserialize)]
415pub(super) struct ListQuery {
416 #[serde(default)]
417 pub state: String,
418 #[serde(default)]
419 pub limit: Option<usize>,
420}
421
422pub(super) async fn handle_work_list(
423 State(state): State<AppState>,
424 Query(q): Query<ListQuery>,
425) -> (StatusCode, Json<Value>) {
426 let limit = q.limit.unwrap_or(50).clamp(1, 500);
427 match state.identity_store.work_queue_list(&q.state, limit) {
428 Ok(items) => (StatusCode::OK, Json(json!({ "items": items }))),
429 Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, format!("list failed: {e}")),
430 }
431}
432
433#[derive(Debug, Default, Deserialize)]
434pub(super) struct CancelRequest {
435 #[serde(default)]
436 pub reason: String,
437}
438
439pub(super) async fn handle_work_cancel(
447 State(state): State<AppState>,
448 Path(id): Path<String>,
449 body: Option<Json<CancelRequest>>,
450) -> (StatusCode, Json<Value>) {
451 let req = body.map(|Json(r)| r).unwrap_or_default();
452 match state.identity_store.work_queue_cancel(&id, &req.reason, now_ms()) {
453 Ok(true) => {
454 publish_changed(&state);
455 (StatusCode::OK, Json(json!({ "ok": true })))
456 }
457 Ok(false) => (
462 StatusCode::CONFLICT,
463 Json(json!({ "ok": false, "error": "no open or claimed item with that id" })),
464 ),
465 Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, format!("cancel failed: {e}")),
466 }
467}