mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
* Add WebSocket gateway and control plane endpoint Adds bidirectional WebSocket transport to the web gateway alongside the existing SSE stream. Clients can send messages, approvals, and pings over a single persistent connection at /api/chat/ws. - Enable axum `ws` feature for built-in WebSocket support - Add WsClientMessage/WsServerMessage types with tagged JSON protocol - Add subscribe_raw() to SseManager for non-SSE consumers - Create ws.rs with connection handler (split sender/receiver tasks) - Add WsConnectionTracker for active connection counting - Add /api/gateway/status control plane endpoint (SSE + WS counts) - 35 new tests covering message types, broadcast, and handler logic https://claude.ai/code/session_01KEaLN6Xq2j5EeV3SGHQT6b * Add e2e WebSocket gateway integration tests - Add tokio-tungstenite dev-dependency for WebSocket client in tests - Update start_server to return actual bound SocketAddr (enables port 0) - Add 10 e2e tests covering full HTTP upgrade → WebSocket → message flow: ping/pong, message routing to agent, broadcast event delivery, connection tracking, invalid message handling, auth rejection, gateway status endpoint, and multi-event sequencing https://claude.ai/code/session_01KEaLN6Xq2j5EeV3SGHQT6b --------- Co-authored-by: Claude <[email protected]>
884 lines
28 KiB
Rust
884 lines
28 KiB
Rust
//! 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<Option<mpsc::Sender<IncomingMessage>>>,
|
|
/// SSE broadcast manager.
|
|
pub sse: SseManager,
|
|
/// Workspace for memory API.
|
|
pub workspace: Option<Arc<Workspace>>,
|
|
/// Context manager for jobs API.
|
|
pub context_manager: Option<Arc<ContextManager>>,
|
|
/// Session manager for thread info.
|
|
pub session_manager: Option<Arc<SessionManager>>,
|
|
/// Log broadcaster for the logs SSE endpoint.
|
|
pub log_broadcaster: Option<Arc<LogBroadcaster>>,
|
|
/// Extension manager for extension management API.
|
|
pub extension_manager: Option<Arc<ExtensionManager>>,
|
|
/// Tool registry for listing registered tools.
|
|
pub tool_registry: Option<Arc<ToolRegistry>>,
|
|
/// User ID for this gateway.
|
|
pub user_id: String,
|
|
/// Shutdown signal sender.
|
|
pub shutdown_tx: tokio::sync::RwLock<Option<oneshot::Sender<()>>>,
|
|
/// WebSocket connection tracker.
|
|
pub ws_tracker: Option<Arc<crate::channels::web::ws::WsConnectionTracker>>,
|
|
}
|
|
|
|
/// 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<GatewayState>,
|
|
auth_token: String,
|
|
) -> Result<SocketAddr, crate::error::ChannelError> {
|
|
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<HealthResponse> {
|
|
Json(HealthResponse {
|
|
status: "healthy",
|
|
channel: "gateway",
|
|
})
|
|
}
|
|
|
|
// --- Chat handlers ---
|
|
|
|
async fn chat_send_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Json(req): Json<SendMessageRequest>,
|
|
) -> Result<(StatusCode, Json<SendMessageResponse>), (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<Arc<GatewayState>>,
|
|
Json(req): Json<ApprovalRequest>,
|
|
) -> Result<(StatusCode, Json<SendMessageResponse>), (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<Arc<GatewayState>>) -> impl IntoResponse {
|
|
// subscribe() returns Sse<impl Stream + 'static + use<>> so no lifetime issues
|
|
state.sse.subscribe()
|
|
}
|
|
|
|
async fn chat_ws_handler(
|
|
ws: WebSocketUpgrade,
|
|
State(state): State<Arc<GatewayState>>,
|
|
) -> impl IntoResponse {
|
|
ws.on_upgrade(move |socket| crate::channels::web::ws::handle_ws_connection(socket, state))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct HistoryQuery {
|
|
thread_id: Option<String>,
|
|
}
|
|
|
|
async fn chat_history_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Query(query): Query<HistoryQuery>,
|
|
) -> Result<Json<HistoryResponse>, (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<TurnInfo> = 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<Arc<GatewayState>>,
|
|
) -> Result<Json<ThreadListResponse>, (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<ThreadInfo> = 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<Arc<GatewayState>>,
|
|
) -> Result<Json<ThreadInfo>, (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<usize>,
|
|
}
|
|
|
|
async fn memory_tree_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Query(_query): Query<TreeQuery>,
|
|
) -> Result<Json<MemoryTreeResponse>, (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<TreeEntry> = Vec::new();
|
|
let mut seen_dirs: std::collections::HashSet<String> = 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<String>,
|
|
}
|
|
|
|
async fn memory_list_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Query(query): Query<ListQuery>,
|
|
) -> Result<Json<MemoryListResponse>, (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<ListEntry> = 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<Arc<GatewayState>>,
|
|
Query(query): Query<ReadQuery>,
|
|
) -> Result<Json<MemoryReadResponse>, (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<Arc<GatewayState>>,
|
|
Json(req): Json<MemoryWriteRequest>,
|
|
) -> Result<Json<MemoryWriteResponse>, (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<Arc<GatewayState>>,
|
|
Json(req): Json<MemorySearchRequest>,
|
|
) -> Result<Json<MemorySearchResponse>, (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<SearchHit> = 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<Arc<GatewayState>>,
|
|
) -> Result<Json<JobListResponse>, (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<Arc<GatewayState>>,
|
|
) -> Result<Json<JobSummaryResponse>, (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<Arc<GatewayState>>,
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<JobInfo>, (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<Arc<GatewayState>>,
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<serde_json::Value>, (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<Arc<GatewayState>>,
|
|
) -> Result<
|
|
Sse<impl futures::Stream<Item = Result<Event, Infallible>> + 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<Arc<GatewayState>>,
|
|
) -> Result<Json<ExtensionListResponse>, (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<Arc<GatewayState>>,
|
|
) -> Result<Json<ToolListResponse>, (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<Arc<GatewayState>>,
|
|
Json(req): Json<InstallExtensionRequest>,
|
|
) -> Result<Json<ActionResponse>, (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<Arc<GatewayState>>,
|
|
Path(name): Path<String>,
|
|
) -> Result<Json<ActionResponse>, (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<Arc<GatewayState>>,
|
|
Path(name): Path<String>,
|
|
) -> Result<Json<ActionResponse>, (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<Arc<GatewayState>>,
|
|
) -> Json<GatewayStatusResponse> {
|
|
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,
|
|
}
|