//! Axum HTTP server for the web gateway. //! //! Handles all API routes: chat, memory, jobs, health, and static file serving. use std::convert::Infallible; use std::net::SocketAddr; use std::sync::Arc; use axum::{ Json, Router, extract::{Path, Query, State, WebSocketUpgrade}, http::{StatusCode, header}, middleware, response::{ Html, IntoResponse, sse::{Event, KeepAlive, Sse}, }, routing::{get, post}, }; use serde::Deserialize; use tokio::sync::{mpsc, oneshot}; use tokio_stream::StreamExt; use uuid::Uuid; use crate::agent::SessionManager; use crate::channels::IncomingMessage; use crate::channels::web::auth::{AuthState, auth_middleware}; use crate::channels::web::log_layer::LogBroadcaster; use crate::channels::web::sse::SseManager; use crate::channels::web::types::*; use crate::context::ContextManager; use crate::extensions::ExtensionManager; use crate::tools::ToolRegistry; use crate::workspace::Workspace; /// Shared state for all gateway handlers. pub struct GatewayState { /// Channel to send messages to the agent loop. pub msg_tx: tokio::sync::RwLock>>, /// SSE broadcast manager. pub sse: SseManager, /// Workspace for memory API. pub workspace: Option>, /// Context manager for jobs API. pub context_manager: Option>, /// Session manager for thread info. pub session_manager: Option>, /// Log broadcaster for the logs SSE endpoint. pub log_broadcaster: Option>, /// Extension manager for extension management API. pub extension_manager: Option>, /// Tool registry for listing registered tools. pub tool_registry: Option>, /// User ID for this gateway. pub user_id: String, /// Shutdown signal sender. pub shutdown_tx: tokio::sync::RwLock>>, /// WebSocket connection tracker. pub ws_tracker: Option>, } /// Start the gateway HTTP server. /// /// Returns the actual bound `SocketAddr` (useful when binding to port 0). pub async fn start_server( addr: SocketAddr, state: Arc, auth_token: String, ) -> Result { let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| { crate::error::ChannelError::StartupFailed { name: "gateway".to_string(), reason: format!("Failed to bind to {}: {}", addr, e), } })?; let bound_addr = listener .local_addr() .map_err(|e| crate::error::ChannelError::StartupFailed { name: "gateway".to_string(), reason: format!("Failed to get local addr: {}", e), })?; // Public routes (no auth) let public = Router::new().route("/api/health", get(health_handler)); // Protected routes (require auth) let auth_state = AuthState { token: auth_token }; let protected = Router::new() // Chat .route("/api/chat/send", post(chat_send_handler)) .route("/api/chat/approval", post(chat_approval_handler)) .route("/api/chat/events", get(chat_events_handler)) .route("/api/chat/ws", get(chat_ws_handler)) .route("/api/chat/history", get(chat_history_handler)) .route("/api/chat/threads", get(chat_threads_handler)) .route("/api/chat/thread/new", post(chat_new_thread_handler)) // Memory .route("/api/memory/tree", get(memory_tree_handler)) .route("/api/memory/list", get(memory_list_handler)) .route("/api/memory/read", get(memory_read_handler)) .route("/api/memory/write", post(memory_write_handler)) .route("/api/memory/search", post(memory_search_handler)) // Jobs .route("/api/jobs", get(jobs_list_handler)) .route("/api/jobs/summary", get(jobs_summary_handler)) .route("/api/jobs/{id}", get(jobs_detail_handler)) .route("/api/jobs/{id}/cancel", post(jobs_cancel_handler)) // Logs .route("/api/logs/events", get(logs_events_handler)) // Extensions .route("/api/extensions", get(extensions_list_handler)) .route("/api/extensions/tools", get(extensions_tools_handler)) .route("/api/extensions/install", post(extensions_install_handler)) .route( "/api/extensions/{name}/activate", post(extensions_activate_handler), ) .route( "/api/extensions/{name}/remove", post(extensions_remove_handler), ) // Gateway control plane .route("/api/gateway/status", get(gateway_status_handler)) .route_layer(middleware::from_fn_with_state(auth_state, auth_middleware)); // Static file routes (no auth, served from embedded strings) let statics = Router::new() .route("/", get(index_handler)) .route("/style.css", get(css_handler)) .route("/app.js", get(js_handler)); let app = Router::new() .merge(public) .merge(statics) .merge(protected) .with_state(state.clone()); let (shutdown_tx, shutdown_rx) = oneshot::channel(); *state.shutdown_tx.write().await = Some(shutdown_tx); tokio::spawn(async move { if let Err(e) = axum::serve(listener, app) .with_graceful_shutdown(async { let _ = shutdown_rx.await; tracing::info!("Web gateway shutting down"); }) .await { tracing::error!("Web gateway server error: {}", e); } }); Ok(bound_addr) } // --- Static file handlers --- async fn index_handler() -> Html<&'static str> { Html(include_str!("static/index.html")) } async fn css_handler() -> impl IntoResponse { ( [(header::CONTENT_TYPE, "text/css")], include_str!("static/style.css"), ) } async fn js_handler() -> impl IntoResponse { ( [(header::CONTENT_TYPE, "application/javascript")], include_str!("static/app.js"), ) } // --- Health --- async fn health_handler() -> Json { Json(HealthResponse { status: "healthy", channel: "gateway", }) } // --- Chat handlers --- async fn chat_send_handler( State(state): State>, Json(req): Json, ) -> Result<(StatusCode, Json), (StatusCode, String)> { let mut msg = IncomingMessage::new("gateway", &state.user_id, &req.content); if let Some(ref thread_id) = req.thread_id { msg = msg.with_thread(thread_id); } let msg_id = msg.id; let tx_guard = state.msg_tx.read().await; let tx = tx_guard.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Channel not started".to_string(), ))?; tx.send(msg).await.map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, "Channel closed".to_string(), ) })?; Ok(( StatusCode::ACCEPTED, Json(SendMessageResponse { message_id: msg_id, status: "accepted", }), )) } async fn chat_approval_handler( State(state): State>, Json(req): Json, ) -> Result<(StatusCode, Json), (StatusCode, String)> { let (approved, always) = match req.action.as_str() { "approve" => (true, false), "always" => (true, true), "deny" => (false, false), other => { return Err(( StatusCode::BAD_REQUEST, format!("Unknown action: {}", other), )); } }; let request_id = Uuid::parse_str(&req.request_id).map_err(|_| { ( StatusCode::BAD_REQUEST, "Invalid request_id (expected UUID)".to_string(), ) })?; // Build a structured ExecApproval submission as JSON, sent through the // existing message pipeline so the agent loop picks it up. let approval = crate::agent::submission::Submission::ExecApproval { request_id, approved, always, }; let content = serde_json::to_string(&approval).map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to serialize approval: {}", e), ) })?; let msg = IncomingMessage::new("gateway", &state.user_id, content); let msg_id = msg.id; let tx_guard = state.msg_tx.read().await; let tx = tx_guard.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Channel not started".to_string(), ))?; tx.send(msg).await.map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, "Channel closed".to_string(), ) })?; Ok(( StatusCode::ACCEPTED, Json(SendMessageResponse { message_id: msg_id, status: "accepted", }), )) } async fn chat_events_handler(State(state): State>) -> impl IntoResponse { // subscribe() returns Sse> so no lifetime issues state.sse.subscribe() } async fn chat_ws_handler( ws: WebSocketUpgrade, State(state): State>, ) -> impl IntoResponse { ws.on_upgrade(move |socket| crate::channels::web::ws::handle_ws_connection(socket, state)) } #[derive(Deserialize)] struct HistoryQuery { thread_id: Option, } async fn chat_history_handler( State(state): State>, Query(query): Query, ) -> Result, (StatusCode, String)> { let session_manager = state.session_manager.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Session manager not available".to_string(), ))?; let session = session_manager.get_or_create_session(&state.user_id).await; let sess = session.lock().await; // Find the thread let thread_id = if let Some(ref tid) = query.thread_id { Uuid::parse_str(tid) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid thread_id".to_string()))? } else { sess.active_thread .ok_or((StatusCode::NOT_FOUND, "No active thread".to_string()))? }; let thread = sess .threads .get(&thread_id) .ok_or((StatusCode::NOT_FOUND, "Thread not found".to_string()))?; let turns: Vec = thread .turns .iter() .map(|t| TurnInfo { turn_number: t.turn_number, user_input: t.user_input.clone(), response: t.response.clone(), state: format!("{:?}", t.state), started_at: t.started_at.to_rfc3339(), completed_at: t.completed_at.map(|dt| dt.to_rfc3339()), tool_calls: t .tool_calls .iter() .map(|tc| ToolCallInfo { name: tc.name.clone(), has_result: tc.result.is_some(), has_error: tc.error.is_some(), }) .collect(), }) .collect(); Ok(Json(HistoryResponse { thread_id, turns })) } async fn chat_threads_handler( State(state): State>, ) -> Result, (StatusCode, String)> { let session_manager = state.session_manager.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Session manager not available".to_string(), ))?; let session = session_manager.get_or_create_session(&state.user_id).await; let sess = session.lock().await; let threads: Vec = sess .threads .values() .map(|t| ThreadInfo { id: t.id, state: format!("{:?}", t.state), turn_count: t.turns.len(), created_at: t.created_at.to_rfc3339(), updated_at: t.updated_at.to_rfc3339(), }) .collect(); Ok(Json(ThreadListResponse { threads, active_thread: sess.active_thread, })) } async fn chat_new_thread_handler( State(state): State>, ) -> Result, (StatusCode, String)> { let session_manager = state.session_manager.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Session manager not available".to_string(), ))?; let session = session_manager.get_or_create_session(&state.user_id).await; let mut sess = session.lock().await; let thread = sess.create_thread(); Ok(Json(ThreadInfo { id: thread.id, state: format!("{:?}", thread.state), turn_count: thread.turns.len(), created_at: thread.created_at.to_rfc3339(), updated_at: thread.updated_at.to_rfc3339(), })) } // --- Memory handlers --- #[derive(Deserialize)] struct TreeQuery { #[allow(dead_code)] depth: Option, } async fn memory_tree_handler( State(state): State>, Query(_query): Query, ) -> Result, (StatusCode, String)> { let workspace = state.workspace.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Workspace not available".to_string(), ))?; // Build tree from list_all (flat list of all paths) let all_paths = workspace .list_all() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Collect unique directories and files let mut entries: Vec = Vec::new(); let mut seen_dirs: std::collections::HashSet = std::collections::HashSet::new(); for path in &all_paths { // Add parent directories let parts: Vec<&str> = path.split('/').collect(); for i in 0..parts.len().saturating_sub(1) { let dir_path = parts[..=i].join("/"); if seen_dirs.insert(dir_path.clone()) { entries.push(TreeEntry { path: dir_path, is_dir: true, }); } } // Add the file itself entries.push(TreeEntry { path: path.clone(), is_dir: false, }); } entries.sort_by(|a, b| a.path.cmp(&b.path)); Ok(Json(MemoryTreeResponse { entries })) } #[derive(Deserialize)] struct ListQuery { path: Option, } async fn memory_list_handler( State(state): State>, Query(query): Query, ) -> Result, (StatusCode, String)> { let workspace = state.workspace.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Workspace not available".to_string(), ))?; let path = query.path.as_deref().unwrap_or(""); let entries = workspace .list(path) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let list_entries: Vec = entries .iter() .map(|e| ListEntry { name: e.path.rsplit('/').next().unwrap_or(&e.path).to_string(), path: e.path.clone(), is_dir: e.is_directory, updated_at: e.updated_at.map(|dt| dt.to_rfc3339()), }) .collect(); Ok(Json(MemoryListResponse { path: path.to_string(), entries: list_entries, })) } #[derive(Deserialize)] struct ReadQuery { path: String, } async fn memory_read_handler( State(state): State>, Query(query): Query, ) -> Result, (StatusCode, String)> { let workspace = state.workspace.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Workspace not available".to_string(), ))?; let doc = workspace .read(&query.path) .await .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?; Ok(Json(MemoryReadResponse { path: query.path, content: doc.content, updated_at: Some(doc.updated_at.to_rfc3339()), })) } async fn memory_write_handler( State(state): State>, Json(req): Json, ) -> Result, (StatusCode, String)> { let workspace = state.workspace.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Workspace not available".to_string(), ))?; workspace .write(&req.path, &req.content) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(MemoryWriteResponse { path: req.path, status: "written", })) } async fn memory_search_handler( State(state): State>, Json(req): Json, ) -> Result, (StatusCode, String)> { let workspace = state.workspace.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Workspace not available".to_string(), ))?; let limit = req.limit.unwrap_or(10); let results = workspace .search(&req.query, limit) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let hits: Vec = results .iter() .map(|r| SearchHit { path: r.document_id.to_string(), content: r.content.clone(), score: r.score as f64, }) .collect(); Ok(Json(MemorySearchResponse { results: hits })) } // --- Jobs handlers --- async fn jobs_list_handler( State(state): State>, ) -> Result, (StatusCode, String)> { let context_manager = state.context_manager.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Context manager not available".to_string(), ))?; let job_ids = context_manager.all_jobs_for(&state.user_id).await; let mut jobs = Vec::new(); for job_id in job_ids { if let Ok(ctx) = context_manager.get_context(job_id).await { jobs.push(JobInfo { id: ctx.job_id, title: ctx.title.clone(), state: ctx.state.to_string(), user_id: ctx.user_id.clone(), created_at: ctx.created_at.to_rfc3339(), started_at: ctx.started_at.map(|dt| dt.to_rfc3339()), }); } } Ok(Json(JobListResponse { jobs })) } async fn jobs_summary_handler( State(state): State>, ) -> Result, (StatusCode, String)> { let context_manager = state.context_manager.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Context manager not available".to_string(), ))?; let summary = context_manager.summary_for(&state.user_id).await; Ok(Json(JobSummaryResponse { total: summary.total, pending: summary.pending, in_progress: summary.in_progress, completed: summary.completed, failed: summary.failed, stuck: summary.stuck, })) } async fn jobs_detail_handler( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { let context_manager = state.context_manager.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Context manager not available".to_string(), ))?; let job_id = Uuid::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; let ctx = context_manager .get_context(job_id) .await .map_err(|_| (StatusCode::NOT_FOUND, "Job not found".to_string()))?; if ctx.user_id != state.user_id { return Err((StatusCode::NOT_FOUND, "Job not found".to_string())); } Ok(Json(JobInfo { id: ctx.job_id, title: ctx.title.clone(), state: ctx.state.to_string(), user_id: ctx.user_id.clone(), created_at: ctx.created_at.to_rfc3339(), started_at: ctx.started_at.map(|dt| dt.to_rfc3339()), })) } async fn jobs_cancel_handler( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { let context_manager = state.context_manager.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Context manager not available".to_string(), ))?; let job_id = Uuid::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; let ctx = context_manager .get_context(job_id) .await .map_err(|_| (StatusCode::NOT_FOUND, "Job not found".to_string()))?; if ctx.user_id != state.user_id { return Err((StatusCode::NOT_FOUND, "Job not found".to_string())); } context_manager .update_context(job_id, |ctx| { ctx.transition_to(crate::context::JobState::Cancelled, None) }) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .map_err(|msg| (StatusCode::CONFLICT, msg))?; Ok(Json(serde_json::json!({ "status": "cancelled", "job_id": job_id, }))) } // --- Logs handlers --- async fn logs_events_handler( State(state): State>, ) -> Result< Sse> + Send + 'static>, (StatusCode, String), > { let broadcaster = state.log_broadcaster.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Log broadcaster not available".to_string(), ))?; // Replay recent history so late-joining browsers see startup logs. // Subscribe BEFORE snapshotting to avoid a gap between history and live. let rx = broadcaster.subscribe(); let history = broadcaster.recent_entries(); let history_stream = futures::stream::iter(history).map(|entry| { let data = serde_json::to_string(&entry).unwrap_or_default(); Ok(Event::default().event("log").data(data)) }); let live_stream = tokio_stream::wrappers::BroadcastStream::new(rx) .filter_map(|result| result.ok()) .map(|entry| { let data = serde_json::to_string(&entry).unwrap_or_default(); Ok(Event::default().event("log").data(data)) }); let stream = history_stream.chain(live_stream); Ok(Sse::new(stream).keep_alive( KeepAlive::new() .interval(std::time::Duration::from_secs(30)) .text(""), )) } // --- Extension handlers --- async fn extensions_list_handler( State(state): State>, ) -> Result, (StatusCode, String)> { let ext_mgr = state.extension_manager.as_ref().ok_or(( StatusCode::NOT_IMPLEMENTED, "Extension manager not available (secrets store required)".to_string(), ))?; let installed = ext_mgr .list(None) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let extensions = installed .into_iter() .map(|ext| ExtensionInfo { name: ext.name, kind: ext.kind.to_string(), description: ext.description, url: ext.url, authenticated: ext.authenticated, active: ext.active, tools: ext.tools, }) .collect(); Ok(Json(ExtensionListResponse { extensions })) } async fn extensions_tools_handler( State(state): State>, ) -> Result, (StatusCode, String)> { let registry = state.tool_registry.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Tool registry not available".to_string(), ))?; let definitions = registry.tool_definitions().await; let tools = definitions .into_iter() .map(|td| ToolInfo { name: td.name, description: td.description, }) .collect(); Ok(Json(ToolListResponse { tools })) } async fn extensions_install_handler( State(state): State>, Json(req): Json, ) -> Result, (StatusCode, String)> { let ext_mgr = state.extension_manager.as_ref().ok_or(( StatusCode::NOT_IMPLEMENTED, "Extension manager not available (secrets store required)".to_string(), ))?; let kind_hint = req.kind.as_deref().and_then(|k| match k { "mcp_server" => Some(crate::extensions::ExtensionKind::McpServer), "wasm_tool" => Some(crate::extensions::ExtensionKind::WasmTool), "wasm_channel" => Some(crate::extensions::ExtensionKind::WasmChannel), _ => None, }); match ext_mgr .install(&req.name, req.url.as_deref(), kind_hint) .await { Ok(result) => Ok(Json(ActionResponse::ok(result.message))), Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))), } } async fn extensions_activate_handler( State(state): State>, Path(name): Path, ) -> Result, (StatusCode, String)> { let ext_mgr = state.extension_manager.as_ref().ok_or(( StatusCode::NOT_IMPLEMENTED, "Extension manager not available (secrets store required)".to_string(), ))?; match ext_mgr.activate(&name).await { Ok(result) => Ok(Json(ActionResponse::ok(result.message))), Err(activate_err) => { let err_str = activate_err.to_string(); let needs_auth = err_str.contains("authentication") || err_str.contains("401") || err_str.contains("Unauthorized"); if !needs_auth { return Ok(Json(ActionResponse::fail(err_str))); } // Activation failed due to auth; try authenticating first. match ext_mgr.auth(&name, None).await { Ok(auth_result) if auth_result.status == "authenticated" => { // Auth succeeded, retry activation. match ext_mgr.activate(&name).await { Ok(result) => Ok(Json(ActionResponse::ok(result.message))), Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))), } } Ok(auth_result) => { // Auth in progress (OAuth URL or awaiting manual token). let mut resp = ActionResponse::fail( auth_result .instructions .clone() .unwrap_or_else(|| format!("'{}' requires authentication.", name)), ); resp.auth_url = auth_result.auth_url; resp.awaiting_token = Some(auth_result.awaiting_token); resp.instructions = auth_result.instructions; Ok(Json(resp)) } Err(auth_err) => Ok(Json(ActionResponse::fail(format!( "Authentication failed: {}", auth_err )))), } } } } async fn extensions_remove_handler( State(state): State>, Path(name): Path, ) -> Result, (StatusCode, String)> { let ext_mgr = state.extension_manager.as_ref().ok_or(( StatusCode::NOT_IMPLEMENTED, "Extension manager not available (secrets store required)".to_string(), ))?; match ext_mgr.remove(&name).await { Ok(message) => Ok(Json(ActionResponse::ok(message))), Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))), } } // --- Gateway control plane handlers --- async fn gateway_status_handler( State(state): State>, ) -> Json { let sse_connections = state.sse.connection_count(); let ws_connections = state .ws_tracker .as_ref() .map(|t| t.connection_count()) .unwrap_or(0); Json(GatewayStatusResponse { sse_connections, ws_connections, total_connections: sse_connections + ws_connections, }) } #[derive(serde::Serialize)] struct GatewayStatusResponse { sse_connections: u64, ws_connections: u64, total_connections: u64, }