refactor(web_ui): share one broker_request helper; /send timeout now 409 not 500
This commit is contained in:
parent
d83d03e4c0
commit
785a36b907
4 changed files with 86 additions and 83 deletions
|
|
@ -8,9 +8,7 @@ use axum::{
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::client;
|
use super::{AppState, error_response};
|
||||||
|
|
||||||
use super::{AppState, SOCKET_FETCH_TIMEOUT, error_response};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub(super) struct SendForm {
|
pub(super) struct SendForm {
|
||||||
|
|
@ -25,33 +23,23 @@ pub(super) async fn post_send(
|
||||||
if body.is_empty() {
|
if body.is_empty() {
|
||||||
return error_response(StatusCode::BAD_REQUEST, "send: `body` required");
|
return error_response(StatusCode::BAD_REQUEST, "send: `body` required");
|
||||||
}
|
}
|
||||||
let result = match tokio::time::timeout(
|
match super::broker_request(&state.socket, &hive_sh4re::Request::OperatorMsg { body }).await {
|
||||||
SOCKET_FETCH_TIMEOUT,
|
|
||||||
client::request::<_, hive_sh4re::Response>(
|
|
||||||
&state.socket,
|
|
||||||
&hive_sh4re::Request::OperatorMsg { body },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Ok(hive_sh4re::Response::Ok)) => Ok(()),
|
|
||||||
Ok(Ok(hive_sh4re::Response::Err { message })) => Err(message),
|
|
||||||
Ok(Ok(other)) => Err(format!("unexpected response: {other:?}")),
|
|
||||||
Ok(Err(e)) => Err(format!("transport: {e:#}")),
|
|
||||||
Err(_) => Err("timed out — hive-c0re busy, retry".to_owned()),
|
|
||||||
};
|
|
||||||
match result {
|
|
||||||
// 200 instead of 303 → the client doesn't refetch /api/state.
|
// 200 instead of 303 → the client doesn't refetch /api/state.
|
||||||
// The operator message becomes a broker `Sent` (already shown
|
// The operator message becomes a broker `Sent` (already shown
|
||||||
// server-side in the dashboard); on the agent side, the
|
// server-side in the dashboard); on the agent side, the
|
||||||
// resulting `TurnStart` SSE event drives the terminal + the
|
// resulting `TurnStart` SSE event drives the terminal + the
|
||||||
// inbox row gets consumed by the time `TurnEnd` fires the
|
// inbox row gets consumed by the time `TurnEnd` fires the
|
||||||
// existing turn-end refresh.
|
// existing turn-end refresh.
|
||||||
Ok(()) => (axum::http::StatusCode::OK, "ok").into_response(),
|
Ok(hive_sh4re::Response::Ok) => (axum::http::StatusCode::OK, "ok").into_response(),
|
||||||
Err(e) => error_response(
|
Ok(hive_sh4re::Response::Err { message }) => error_response(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
&format!("send failed: {e}"),
|
&format!("send failed: {message}"),
|
||||||
),
|
),
|
||||||
|
Ok(other) => error_response(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
&format!("send failed: unexpected response: {other:?}"),
|
||||||
|
),
|
||||||
|
Err(e) => super::broker_error_response(&e, "send"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -267,3 +267,52 @@ fn error_response(status: StatusCode, message: &str) -> Response {
|
||||||
// in its alert, so a benign "busy, retry" must not read as a 500.
|
// in its alert, so a benign "busy, retry" must not read as a 500.
|
||||||
(status, message.to_owned()).into_response()
|
(status, message.to_owned()).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Why a deadline-bounded broker request via the per-agent socket didn't
|
||||||
|
/// yield a response. Kept distinct so action handlers pick the right status
|
||||||
|
/// code (see [`broker_error_response`]) while decorative fetches `.ok()` both.
|
||||||
|
enum BrokerError {
|
||||||
|
/// Outran [`SOCKET_FETCH_TIMEOUT`] — hive-c0re is busy or stalled. A
|
||||||
|
/// retryable state conflict (→ 409), not a server fault.
|
||||||
|
Timeout,
|
||||||
|
/// The socket transport itself failed (connect / encode / decode).
|
||||||
|
Transport(anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue a broker request over the per-agent socket, bounded by
|
||||||
|
/// [`SOCKET_FETCH_TIMEOUT`] so a busy or stalled hive-c0re degrades the
|
||||||
|
/// response instead of hanging it. Callers match the returned [`Response`]
|
||||||
|
/// variant themselves; the error side distinguishes a retryable timeout from
|
||||||
|
/// a transport failure. This is the one shared broker-call scaffold — every
|
||||||
|
/// web-UI handler that talks to the broker goes through it.
|
||||||
|
async fn broker_request(
|
||||||
|
socket: &Path,
|
||||||
|
req: &hive_sh4re::Request,
|
||||||
|
) -> std::result::Result<hive_sh4re::Response, BrokerError> {
|
||||||
|
match tokio::time::timeout(
|
||||||
|
SOCKET_FETCH_TIMEOUT,
|
||||||
|
crate::client::request::<_, hive_sh4re::Response>(socket, req),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(resp)) => Ok(resp),
|
||||||
|
Ok(Err(e)) => Err(BrokerError::Transport(e)),
|
||||||
|
Err(_) => Err(BrokerError::Timeout),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a [`BrokerError`] to an operator-facing error response: a timeout is a
|
||||||
|
/// retryable "busy" conflict (409), a transport failure is a 500. `action`
|
||||||
|
/// prefixes the message (e.g. `"send"`, `"get_loose_ends"`).
|
||||||
|
fn broker_error_response(err: &BrokerError, action: &str) -> Response {
|
||||||
|
match err {
|
||||||
|
BrokerError::Timeout => error_response(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
&format!("{action}: timed out — hive-c0re busy, retry"),
|
||||||
|
),
|
||||||
|
BrokerError::Transport(e) => error_response(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
&format!("{action}: transport: {e:#}"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,10 @@
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::client;
|
|
||||||
use crate::login::LoginState;
|
use crate::login::LoginState;
|
||||||
use crate::login_session::drop_if_finished;
|
use crate::login_session::drop_if_finished;
|
||||||
|
|
||||||
use super::{AppState, SOCKET_FETCH_TIMEOUT};
|
use super::AppState;
|
||||||
|
|
||||||
pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
||||||
// Capture seq *before* any reads so the dedupe contract is
|
// Capture seq *before* any reads so the dedupe contract is
|
||||||
|
|
@ -371,18 +370,10 @@ struct ExtraLink {
|
||||||
/// failure — the inbox section is decorative, not authoritative.
|
/// failure — the inbox section is decorative, not authoritative.
|
||||||
async fn recent_inbox(socket: &std::path::Path) -> Vec<hive_sh4re::InboxRow> {
|
async fn recent_inbox(socket: &std::path::Path) -> Vec<hive_sh4re::InboxRow> {
|
||||||
const LIMIT: u64 = 30;
|
const LIMIT: u64 = 30;
|
||||||
// Deadline-bounded: `/api/state` must render even when hive-c0re is
|
// Deadline-bounded (via `broker_request`): `/api/state` must render even
|
||||||
// busy — an empty inbox section beats a hung snapshot.
|
// when hive-c0re is busy — an empty inbox section beats a hung snapshot.
|
||||||
match tokio::time::timeout(
|
match super::broker_request(socket, &hive_sh4re::Request::Recent { limit: LIMIT }).await {
|
||||||
SOCKET_FETCH_TIMEOUT,
|
Ok(hive_sh4re::Response::Recent { rows }) => rows,
|
||||||
client::request::<_, hive_sh4re::Response>(
|
|
||||||
socket,
|
|
||||||
&hive_sh4re::Request::Recent { limit: LIMIT },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Ok(hive_sh4re::Response::Recent { rows })) => rows,
|
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -394,19 +385,16 @@ pub(super) async fn fetch_reminder_stats(
|
||||||
socket: &std::path::Path,
|
socket: &std::path::Path,
|
||||||
window_secs: u64,
|
window_secs: u64,
|
||||||
) -> Option<hive_sh4re::ReminderStats> {
|
) -> Option<hive_sh4re::ReminderStats> {
|
||||||
match tokio::time::timeout(
|
match super::broker_request(
|
||||||
SOCKET_FETCH_TIMEOUT,
|
socket,
|
||||||
client::request::<_, hive_sh4re::Response>(
|
&hive_sh4re::Request::ReminderRollup {
|
||||||
socket,
|
since_secs: window_secs,
|
||||||
&hive_sh4re::Request::ReminderRollup {
|
agent: None,
|
||||||
since_secs: window_secs,
|
},
|
||||||
agent: None,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(Ok(hive_sh4re::Response::ReminderRollup(stats))) => Some(stats),
|
Ok(hive_sh4re::Response::ReminderRollup(stats)) => Some(stats),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,8 @@ use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::client;
|
|
||||||
|
|
||||||
use super::state::fetch_reminder_stats;
|
use super::state::fetch_reminder_stats;
|
||||||
use super::{AppState, SOCKET_FETCH_TIMEOUT, error_response};
|
use super::{AppState, error_response};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub(super) struct StatsQuery {
|
pub(super) struct StatsQuery {
|
||||||
|
|
@ -37,42 +35,22 @@ pub(super) async fn api_stats(
|
||||||
/// the `mcp__hyperhive__get_loose_ends` tool sees from inside the
|
/// the `mcp__hyperhive__get_loose_ends` tool sees from inside the
|
||||||
/// container.
|
/// container.
|
||||||
pub(super) async fn api_loose_ends(State(state): State<AppState>) -> Response {
|
pub(super) async fn api_loose_ends(State(state): State<AppState>) -> Response {
|
||||||
let loose_ends: Vec<hive_sh4re::LooseEnd> = match tokio::time::timeout(
|
match super::broker_request(&state.socket, &hive_sh4re::Request::GetLooseEnds { agent: None })
|
||||||
SOCKET_FETCH_TIMEOUT,
|
.await
|
||||||
client::request::<_, hive_sh4re::Response>(
|
|
||||||
&state.socket,
|
|
||||||
&hive_sh4re::Request::GetLooseEnds { agent: None },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
Ok(Ok(hive_sh4re::Response::LooseEnds { loose_ends })) => loose_ends,
|
Ok(hive_sh4re::Response::LooseEnds { loose_ends }) => {
|
||||||
Ok(Ok(hive_sh4re::Response::Err { message })) => {
|
axum::Json(serde_json::json!({ "loose_ends": loose_ends })).into_response()
|
||||||
return error_response(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
&format!("get_loose_ends: {message}"),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Ok(Ok(other)) => {
|
Ok(hive_sh4re::Response::Err { message }) => error_response(
|
||||||
return error_response(
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
&format!("get_loose_ends: {message}"),
|
||||||
&format!("unexpected response: {other:?}"),
|
),
|
||||||
);
|
Ok(other) => error_response(
|
||||||
}
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Ok(Err(e)) => {
|
&format!("get_loose_ends: unexpected response: {other:?}"),
|
||||||
return error_response(
|
),
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
Err(e) => super::broker_error_response(&e, "get_loose_ends"),
|
||||||
&format!("transport: {e:#}"),
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
return error_response(
|
|
||||||
StatusCode::CONFLICT,
|
|
||||||
"get_loose_ends: timed out — hive-c0re busy, retry",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
axum::Json(serde_json::json!({ "loose_ends": loose_ends })).into_response()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/bash-tasks` — snapshot of this agent's in-flight bash tasks.
|
/// `GET /api/bash-tasks` — snapshot of this agent's in-flight bash tasks.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue