Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<WebSocketUpgrade>` 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:
Expand Down
6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
2 changes: 1 addition & 1 deletion ailoop-cli/src/cli/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion ailoop-py/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 9 additions & 9 deletions ailoop-server/src/server/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,11 +169,11 @@ pub(crate) fn create_api_router() -> axum::Router<AppState> {
.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))
Expand All @@ -184,18 +184,18 @@ pub(crate) fn create_api_router() -> axum::Router<AppState> {
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",
Expand All @@ -206,19 +206,19 @@ pub(crate) fn create_api_router() -> axum::Router<AppState> {
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),
)
}
Expand Down
2 changes: 1 addition & 1 deletion ailoop-server/src/server/broadcast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
52 changes: 30 additions & 22 deletions ailoop-server/src/server/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;

Expand Down Expand Up @@ -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()));
}
}
}
Expand Down Expand Up @@ -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<WebSocketUpgrade>` 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<AiloopAppState>,
ws: Option<WebSocketUpgrade>,
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),
}
}

Expand Down Expand Up @@ -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 {
Expand Down
Loading