Merge forge/main into atlas/5034-s4-remove-hive-model
This commit is contained in:
commit
a7e640514c
2 changed files with 139 additions and 7 deletions
|
|
@ -8,6 +8,7 @@ use axum::{
|
|||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::future::Future;
|
||||
|
||||
use super::{AppState, SigintOutcome, error_response};
|
||||
|
||||
|
|
@ -143,7 +144,9 @@ pub(super) struct MarkTodosDoneForm {
|
|||
/// MCP tool — unchanged. Unknown/already-acked ids just don't add to the
|
||||
/// `acked` count (same "acking twice is not a new action" semantics as the
|
||||
/// single-id path); a request with no ids or where every id fails to parse
|
||||
/// is rejected as a client error rather than silently acking nothing.
|
||||
/// is rejected as a client error rather than silently acking nothing. An id
|
||||
/// whose store call errors or gets no answer fails the request with a 500
|
||||
/// naming how many failed.
|
||||
pub(super) async fn post_mark_todos_done(Form(form): Form<MarkTodosDoneForm>) -> Response {
|
||||
let ids: Vec<i64> = form
|
||||
.ids
|
||||
|
|
@ -153,18 +156,144 @@ pub(super) async fn post_mark_todos_done(Form(form): Form<MarkTodosDoneForm>) ->
|
|||
if ids.is_empty() {
|
||||
return error_response(StatusCode::BAD_REQUEST, "mark-done: no todo ids selected");
|
||||
}
|
||||
let mut acked = 0u64;
|
||||
mark_ids_done(ids, |id| async move {
|
||||
crate::todo_server::dial(&hive_agent_sock::Request::MarkTodoDone { id }).await
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The handler body behind `dial`, which answers one id's `MarkTodoDone`
|
||||
/// (`None` when the store doesn't answer). Tests inject it so they never
|
||||
/// reach the live in-agent socket.
|
||||
async fn mark_ids_done<F, Fut>(ids: Vec<i64>, dial: F) -> Response
|
||||
where
|
||||
F: Fn(i64) -> Fut,
|
||||
Fut: Future<Output = Option<hive_agent_sock::Response>>,
|
||||
{
|
||||
let mut answers = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
if let Some(hive_agent_sock::Response::Acked { count }) =
|
||||
crate::todo_server::dial(&hive_agent_sock::Request::MarkTodoDone { id }).await
|
||||
{
|
||||
acked += count;
|
||||
answers.push(dial(id).await);
|
||||
}
|
||||
match settle_mark_done(answers) {
|
||||
Ok(acked) => axum::Json(MarkTodosDoneBody { acked }).into_response(),
|
||||
Err(message) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &message),
|
||||
}
|
||||
}
|
||||
|
||||
/// Folds the per-id answers into the acked total, or the operator-facing
|
||||
/// failure. `None` is a dial that got no answer; `Response::Err` and any
|
||||
/// other variant are failures. `Acked { count: 0 }` is a success.
|
||||
fn settle_mark_done(answers: Vec<Option<hive_agent_sock::Response>>) -> Result<u64, String> {
|
||||
let total = answers.len();
|
||||
let mut acked = 0u64;
|
||||
let mut failed = 0usize;
|
||||
let mut first_error = None;
|
||||
for answer in answers {
|
||||
match answer {
|
||||
Some(hive_agent_sock::Response::Acked { count }) => acked += count,
|
||||
Some(hive_agent_sock::Response::Err { message }) => {
|
||||
failed += 1;
|
||||
first_error.get_or_insert(message);
|
||||
}
|
||||
Some(other) => {
|
||||
failed += 1;
|
||||
first_error.get_or_insert(format!("unexpected response: {other:?}"));
|
||||
}
|
||||
None => {
|
||||
failed += 1;
|
||||
first_error.get_or_insert_with(|| "the todo store did not answer".to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
axum::Json(MarkTodosDoneBody { acked }).into_response()
|
||||
match first_error {
|
||||
None => Ok(acked),
|
||||
Some(error) => Err(format!(
|
||||
"mark-done: {failed} of {total} failed ({acked} marked done): {error}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MarkTodosDoneBody {
|
||||
acked: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{mark_ids_done, settle_mark_done};
|
||||
|
||||
async fn body_text(response: axum::response::Response) -> String {
|
||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
String::from_utf8(bytes.to_vec()).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mark_done_handler_answers_500_with_the_message_when_the_store_does_not_answer() {
|
||||
let response = mark_ids_done(vec![1, 2], |_| async { None }).await;
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR
|
||||
);
|
||||
let body = body_text(response).await;
|
||||
assert!(body.contains("2 of 2 failed"), "{body}");
|
||||
assert!(body.contains("did not answer"), "{body}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mark_done_handler_answers_500_with_the_store_error() {
|
||||
let response = mark_ids_done(vec![7], |_| async {
|
||||
Some(hive_agent_sock::Response::Err {
|
||||
message: "database is locked".to_owned(),
|
||||
})
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR
|
||||
);
|
||||
assert!(body_text(response).await.contains("database is locked"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mark_done_handler_answers_200_with_the_acked_total() {
|
||||
let response = mark_ids_done(vec![1, 2], |_| async {
|
||||
Some(hive_agent_sock::Response::Acked { count: 1 })
|
||||
})
|
||||
.await;
|
||||
assert_eq!(response.status(), axum::http::StatusCode::OK);
|
||||
assert_eq!(body_text(response).await, r#"{"acked":2}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_done_with_no_answer_is_an_error() {
|
||||
let err = settle_mark_done(vec![None]).unwrap_err();
|
||||
assert!(err.contains("1 of 1 failed"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_done_surfaces_the_store_error() {
|
||||
let answers = vec![Some(hive_agent_sock::Response::Err {
|
||||
message: "database is locked".to_owned(),
|
||||
})];
|
||||
let err = settle_mark_done(answers).unwrap_err();
|
||||
assert!(err.contains("database is locked"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_done_partial_failure_reports_both_counts() {
|
||||
let answers = vec![Some(hive_agent_sock::Response::Acked { count: 1 }), None];
|
||||
let err = settle_mark_done(answers).unwrap_err();
|
||||
assert!(err.contains("1 of 2 failed (1 marked done)"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_done_acking_nothing_new_is_still_success() {
|
||||
let answers = vec![
|
||||
Some(hive_agent_sock::Response::Acked { count: 0 }),
|
||||
Some(hive_agent_sock::Response::Acked { count: 2 }),
|
||||
];
|
||||
assert_eq!(settle_mark_done(answers).unwrap(), 2);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue