From 1c6fd69d71b7ccf3519dfb1a8deae46d787192e6 Mon Sep 17 00:00:00 2001 From: Alex Oliveira <4482374+aroff@users.noreply.github.com> Date: Thu, 14 May 2026 09:02:48 +0000 Subject: [PATCH] chore(develop): 59-upgrade-ailoop-server-to-axum-0-8-newton-embed-compatibility --- CHANGELOG.md | 35 ++++++++++++++++++ Cargo.toml | 6 ++-- ailoop-cli/src/cli/handlers.rs | 2 +- ailoop-py/uv.lock | 2 +- ailoop-server/src/server/api.rs | 18 +++++----- ailoop-server/src/server/broadcast.rs | 2 +- ailoop-server/src/server/core.rs | 52 +++++++++++++++------------ 7 files changed, 80 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbadb62..b1ffd64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,41 @@ ## [Unreleased] +### HTTP Stack Upgrade — Axum 0.8 / Tower 0.5 / tower-http 0.6 + +The workspace HTTP stack has been upgraded for Axum 0.8 compatibility. This unblocks downstream +embedders (e.g. Newton) that pin `axum = "0.8"` from merging `ailoop_server::router()` directly. + +#### Dependency changes + +| Crate | Before | After | +|---|---|---| +| `axum` | `0.7` | `0.8` | +| `tower` | `0.4` | `0.5` | +| `tower-http` | `0.5` | `0.6` | + +#### Embedder migration + +Downstream crates that embed `ailoop_server::router()` must update their own `axum` dependency +to `"0.8"` before merging the returned `Router`. No call-site changes are required to `router()`, +`spawn_background_tasks()`, `ServeConfig`, `AuthConfig`, or `CorsConfig`. + +```toml +# In your embedder's Cargo.toml +axum = "0.8" +``` + +#### Internal signature changes (not public API) + +- `AiloopServer::start_with_shutdown`: the `.into_make_service()` call on the built `Router` has + been removed; `axum::serve` in Axum 0.8 accepts a `Router` directly. +- `root_handler` (internal): `Option` is no longer a valid Axum 0.8 extractor + because `WebSocketUpgrade` does not implement `OptionalFromRequestParts`. The handler now accepts + a raw `axum::extract::Request` and manually attempts the WebSocket upgrade; the observable + routing behaviour (WS upgrade if requested, UI/404 otherwise) is unchanged. +- `WsMessage::Text`: the `Text` variant in Axum 0.8 holds `Utf8Bytes` instead of `String`; all + construction sites updated with `.into()`. + ### Embedder API (ailoop-server — Epic 50) The `ailoop-server` crate now exposes a composable library API for embedding the ailoop HIL surface into any Rust service: diff --git a/Cargo.toml b/Cargo.toml index c988a66..d74331a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,9 +17,9 @@ tokio-tungstenite = "0.21" futures-util = "0.3" # Web framework for server -axum = { version = "0.7", features = ["ws"] } -tower = "0.4" -tower-http = { version = "0.5", features = ["trace", "cors"] } +axum = { version = "0.8", features = ["ws"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["trace", "cors"] } # Serialization serde = { version = "1.0", features = ["derive"] } diff --git a/ailoop-cli/src/cli/handlers.rs b/ailoop-cli/src/cli/handlers.rs index 038a799..7e62d1d 100644 --- a/ailoop-cli/src/cli/handlers.rs +++ b/ailoop-cli/src/cli/handlers.rs @@ -735,7 +735,7 @@ pub async fn handle_serve(host: String, port: u16, channel: String, web: bool) - .await .map_err(|e| anyhow::anyhow!("Failed to bind to {}: {}", address, e))?; - axum::serve(listener, built_router.into_make_service()) + axum::serve(listener, built_router) .with_graceful_shutdown(async move { let _ = tokio::signal::ctrl_c().await; token_for_shutdown.cancel(); diff --git a/ailoop-py/uv.lock b/ailoop-py/uv.lock index e62e311..a0debe7 100644 --- a/ailoop-py/uv.lock +++ b/ailoop-py/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11" [[package]] name = "ailoop-py" -version = "1.0.7" +version = "1.0.8" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/ailoop-server/src/server/api.rs b/ailoop-server/src/server/api.rs index 6d20373..1030fdb 100644 --- a/ailoop-server/src/server/api.rs +++ b/ailoop-server/src/server/api.rs @@ -169,11 +169,11 @@ pub(crate) fn create_api_router() -> axum::Router { .route("/api/test", axum::routing::post(handle_post_test)) .route("/api/channels", axum::routing::get(handle_get_channels)) .route( - "/api/channels/:channel/messages", + "/api/channels/{channel}/messages", axum::routing::get(handle_get_channel_messages), ) .route( - "/api/channels/:channel/stats", + "/api/channels/{channel}/stats", axum::routing::get(handle_get_channel_stats), ) .route("/api/stats", axum::routing::get(handle_get_stats)) @@ -184,18 +184,18 @@ pub(crate) fn create_api_router() -> axum::Router { axum::routing::post(handle_post_messages), ) .route( - "/api/v1/messages/:id", + "/api/v1/messages/{id}", axum::routing::get(handle_get_message), ) .route( - "/api/v1/messages/:id/response", + "/api/v1/messages/{id}/response", axum::routing::post(handle_post_response), ) .route( "/api/v1/tasks", axum::routing::post(handle_post_tasks).get(handle_get_tasks), ) - // literal-segment routes BEFORE parameterized :id to prevent "ready"/"blocked" being + // literal-segment routes BEFORE parameterized {id} to prevent "ready"/"blocked" being // matched as UUIDs .route( "/api/v1/tasks/ready", @@ -206,19 +206,19 @@ pub(crate) fn create_api_router() -> axum::Router { axum::routing::get(handle_get_blocked_tasks), ) .route( - "/api/v1/tasks/:id", + "/api/v1/tasks/{id}", axum::routing::get(handle_get_task).put(handle_put_task), ) .route( - "/api/v1/tasks/:id/dependencies", + "/api/v1/tasks/{id}/dependencies", axum::routing::post(handle_post_task_dependencies).get(handle_get_task_dependencies), ) .route( - "/api/v1/tasks/:id/dependencies/:dep_id", + "/api/v1/tasks/{id}/dependencies/{dep_id}", axum::routing::delete(handle_delete_task_dependency), ) .route( - "/api/v1/tasks/:id/graph", + "/api/v1/tasks/{id}/graph", axum::routing::get(handle_get_task_graph), ) } diff --git a/ailoop-server/src/server/broadcast.rs b/ailoop-server/src/server/broadcast.rs index 9084bb7..d505d01 100644 --- a/ailoop-server/src/server/broadcast.rs +++ b/ailoop-server/src/server/broadcast.rs @@ -194,7 +194,7 @@ impl BroadcastManager { } }; - let ws_message = WsMessage::Text(json_message); + let ws_message = WsMessage::Text(json_message.into()); // Get subscribers for this channel (and viewers subscribed to all channels) let mut all_subscribers = { diff --git a/ailoop-server/src/server/core.rs b/ailoop-server/src/server/core.rs index 4ec0f41..4916eeb 100644 --- a/ailoop-server/src/server/core.rs +++ b/ailoop-server/src/server/core.rs @@ -132,7 +132,7 @@ impl AiloopServer { token_for_shutdown.cancel(); }; - axum::serve(listener, built_router.into_make_service()) + axum::serve(listener, built_router) .with_graceful_shutdown(shutdown_future) .await?; @@ -210,7 +210,7 @@ impl AiloopServer { let msgs = message_history.get_messages(&ch, Some(500)).await; for m in msgs { if let Ok(j) = serde_json::to_string(&m) { - let _ = tx_replay.send(WsMessage::Text(j)); + let _ = tx_replay.send(WsMessage::Text(j.into())); } } } @@ -1082,28 +1082,36 @@ fn strip_markdown(input: &str) -> String { } /// Axum handler: root GET — WebSocket upgrade or web UI fallback. +/// +/// In axum 0.8, `Option` is no longer a valid extractor (WebSocketUpgrade does +/// not implement `OptionalFromRequestParts`). Instead we extract the raw Request and attempt the +/// upgrade manually; any non-WebSocket request falls through to the UI / 404 path. async fn root_handler( State(state): State, - ws: Option, + req: axum::extract::Request, ) -> axum::response::Response { - if let Some(upgrade) = ws { - let channel_manager = Arc::clone(&state.channel_manager); - let default_channel = state.default_channel.clone(); - let message_history = Arc::clone(&state.message_history); - let broadcast_manager = Arc::clone(&state.broadcast_manager); - upgrade - .on_upgrade(move |socket| { - AiloopServer::handle_ws_connection_inner( - socket, - channel_manager, - default_channel, - message_history, - broadcast_manager, - ) - }) - .into_response() - } else { - serve_embedded_ui_or_404(state.web) + use axum::extract::FromRequestParts; + let (mut parts, _body) = req.into_parts(); + + match WebSocketUpgrade::from_request_parts(&mut parts, &state).await { + Ok(upgrade) => { + let channel_manager = Arc::clone(&state.channel_manager); + let default_channel = state.default_channel.clone(); + let message_history = Arc::clone(&state.message_history); + let broadcast_manager = Arc::clone(&state.broadcast_manager); + upgrade + .on_upgrade(move |socket| { + AiloopServer::handle_ws_connection_inner( + socket, + channel_manager, + default_channel, + message_history, + broadcast_manager, + ) + }) + .into_response() + } + Err(_) => serve_embedded_ui_or_404(state.web), } } @@ -1162,7 +1170,7 @@ pub fn router( let inner = inner.layer(cors_layer); // Nest under base_path prefix if configured. - // In axum 0.7, nest("/hil/", inner) matches both /hil/ and /hil/foo; nest("/hil", inner) + // In axum 0.8, nest("/hil/", inner) matches both /hil/ and /hil/foo; nest("/hil", inner) // would not match /hil/ (only /hil/foo). Appending a trailing slash ensures the WS root // path (GET {base_path}/) is reachable. if let Some(prefix) = base_path {