//! 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 std::sync::atomic::{AtomicU64, Ordering}; use axum::{ Json, Router, extract::{DefaultBodyLimit, 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 tower_http::cors::{AllowHeaders, CorsLayer}; 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::db::Database; use crate::extensions::ExtensionManager; use crate::orchestrator::job_manager::ContainerJobManager; use crate::tools::ToolRegistry; use crate::workspace::Workspace; /// Shared prompt queue: maps job IDs to pending follow-up prompts for Claude Code bridges. pub type PromptQueue = Arc< tokio::sync::Mutex< std::collections::HashMap< uuid::Uuid, std::collections::VecDeque, >, >, >; /// Simple sliding-window rate limiter. /// /// Tracks the number of requests in the current window. Resets when the window expires. /// Not per-IP (since this is a single-user gateway with auth), but prevents flooding. pub struct RateLimiter { /// Requests remaining in the current window. remaining: AtomicU64, /// Epoch second when the current window started. window_start: AtomicU64, /// Maximum requests per window. max_requests: u64, /// Window duration in seconds. window_secs: u64, } impl RateLimiter { pub fn new(max_requests: u64, window_secs: u64) -> Self { Self { remaining: AtomicU64::new(max_requests), window_start: AtomicU64::new( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(), ), max_requests, window_secs, } } /// Try to consume one request. Returns `true` if allowed, `false` if rate limited. pub fn check(&self) -> bool { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); let window = self.window_start.load(Ordering::Relaxed); if now.saturating_sub(window) >= self.window_secs { // Window expired, reset self.window_start.store(now, Ordering::Relaxed); self.remaining .store(self.max_requests - 1, Ordering::Relaxed); return true; } // Try to decrement remaining loop { let current = self.remaining.load(Ordering::Relaxed); if current == 0 { return false; } if self .remaining .compare_exchange_weak(current, current - 1, Ordering::Relaxed, Ordering::Relaxed) .is_ok() { return true; } } } } /// 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>, /// 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>, /// Database store for sandbox job persistence. pub store: Option>, /// Container job manager for sandbox operations. pub job_manager: Option>, /// Prompt queue for Claude Code follow-up prompts. pub prompt_queue: 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>, /// LLM provider for OpenAI-compatible API proxy. pub llm_provider: Option>, /// Rate limiter for chat endpoints (30 messages per 60 seconds). pub chat_rate_limiter: RateLimiter, } /// 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/auth-token", post(chat_auth_token_handler)) .route("/api/chat/auth-cancel", post(chat_auth_cancel_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)) .route("/api/jobs/{id}/restart", post(jobs_restart_handler)) .route("/api/jobs/{id}/prompt", post(jobs_prompt_handler)) .route("/api/jobs/{id}/events", get(jobs_events_handler)) .route("/api/jobs/{id}/files/list", get(job_files_list_handler)) .route("/api/jobs/{id}/files/read", get(job_files_read_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), ) // Routines .route("/api/routines", get(routines_list_handler)) .route("/api/routines/summary", get(routines_summary_handler)) .route("/api/routines/{id}", get(routines_detail_handler)) .route("/api/routines/{id}/trigger", post(routines_trigger_handler)) .route("/api/routines/{id}/toggle", post(routines_toggle_handler)) .route( "/api/routines/{id}", axum::routing::delete(routines_delete_handler), ) .route("/api/routines/{id}/runs", get(routines_runs_handler)) // Settings .route("/api/settings", get(settings_list_handler)) .route("/api/settings/export", get(settings_export_handler)) .route("/api/settings/import", post(settings_import_handler)) .route("/api/settings/{key}", get(settings_get_handler)) .route( "/api/settings/{key}", axum::routing::put(settings_set_handler), ) .route( "/api/settings/{key}", axum::routing::delete(settings_delete_handler), ) // Gateway control plane .route("/api/gateway/status", get(gateway_status_handler)) // OpenAI-compatible API .route( "/v1/chat/completions", post(super::openai_compat::chat_completions_handler), ) .route("/v1/models", get(super::openai_compat::models_handler)) .route_layer(middleware::from_fn_with_state( auth_state.clone(), 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)); // Project file serving (behind auth to prevent unauthorized file access). let projects = Router::new() .route("/projects/{project_id}", get(project_redirect_handler)) .route("/projects/{project_id}/", get(project_index_handler)) .route("/projects/{project_id}/{*path}", get(project_file_handler)) .route_layer(middleware::from_fn_with_state( auth_state.clone(), auth_middleware, )); // CORS: restrict to same-origin by default. Only localhost/127.0.0.1 // origins are allowed, since the gateway is a local-first service. let cors = CorsLayer::new() .allow_origin([ format!("http://{}:{}", addr.ip(), addr.port()) .parse() .expect("valid origin"), format!("http://localhost:{}", addr.port()) .parse() .expect("valid origin"), ]) .allow_methods([ axum::http::Method::GET, axum::http::Method::POST, axum::http::Method::PUT, axum::http::Method::DELETE, ]) .allow_headers(AllowHeaders::list([ header::CONTENT_TYPE, header::AUTHORIZATION, ])) .allow_credentials(true); let app = Router::new() .merge(public) .merge(statics) .merge(projects) .merge(protected) .layer(cors) .layer(DefaultBodyLimit::max(1024 * 1024)) // 1 MB max request body .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)> { if !state.chat_rate_limiter.check() { return Err(( StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded. Try again shortly.".to_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); msg = msg.with_metadata(serde_json::json!({"thread_id": 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 mut msg = IncomingMessage::new("gateway", &state.user_id, 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", }), )) } /// Submit an auth token directly to the extension manager, bypassing the message pipeline. /// /// The token never touches the LLM, chat history, or SSE stream. async fn chat_auth_token_handler( State(state): State>, Json(req): Json, ) -> Result, (StatusCode, String)> { let ext_mgr = state.extension_manager.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Extension manager not available".to_string(), ))?; let result = ext_mgr .auth(&req.extension_name, Some(&req.token)) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if result.status == "authenticated" { // Auto-activate so tools are available immediately let msg = match ext_mgr.activate(&req.extension_name).await { Ok(r) => format!( "{} authenticated ({} tools loaded)", req.extension_name, r.tools_loaded.len() ), Err(e) => format!( "{} authenticated but activation failed: {}", req.extension_name, e ), }; // Clear auth mode on the active thread clear_auth_mode(&state).await; state.sse.broadcast(SseEvent::AuthCompleted { extension_name: req.extension_name, success: true, message: msg.clone(), }); Ok(Json(ActionResponse::ok(msg))) } else { // Re-emit auth_required for retry state.sse.broadcast(SseEvent::AuthRequired { extension_name: req.extension_name.clone(), instructions: result.instructions.clone(), auth_url: result.auth_url.clone(), setup_url: result.setup_url.clone(), }); Ok(Json(ActionResponse::fail( result .instructions .unwrap_or_else(|| "Invalid token".to_string()), ))) } } /// Cancel an in-progress auth flow. async fn chat_auth_cancel_handler( State(state): State>, Json(_req): Json, ) -> Result, (StatusCode, String)> { clear_auth_mode(&state).await; Ok(Json(ActionResponse::ok("Auth cancelled"))) } /// Clear pending auth mode on the active thread. pub async fn clear_auth_mode(state: &GatewayState) { if let Some(ref sm) = state.session_manager { let session = sm.get_or_create_session(&state.user_id).await; let mut sess = session.lock().await; if let Some(thread_id) = sess.active_thread { if let Some(thread) = sess.threads.get_mut(&thread_id) { thread.pending_auth = None; } } } } async fn chat_events_handler( State(state): State>, ) -> Result { state.sse.subscribe().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Too many connections".to_string(), )) } async fn chat_ws_handler( headers: axum::http::HeaderMap, ws: WebSocketUpgrade, State(state): State>, ) -> Result { // Validate Origin header to prevent cross-site WebSocket hijacking. // Require the header outright; browsers always send it for WS upgrades, // so a missing Origin means a non-browser client trying to bypass the check. let origin = headers .get("origin") .and_then(|v| v.to_str().ok()) .ok_or_else(|| { ( StatusCode::FORBIDDEN, "WebSocket Origin header required".to_string(), ) })?; // Extract the host from the origin and compare exactly, so that // crafted origins like "http://localhost.evil.com" are rejected. // Origin format is "scheme://host[:port]". let host = origin .strip_prefix("http://") .or_else(|| origin.strip_prefix("https://")) .and_then(|rest| rest.split(':').next()?.split('/').next()) .unwrap_or(""); let is_local = matches!(host, "localhost" | "127.0.0.1" | "[::1]"); if !is_local { return Err(( StatusCode::FORBIDDEN, "WebSocket origin not allowed".to_string(), )); } Ok(ws.on_upgrade(move |socket| crate::channels::web::ws::handle_ws_connection(socket, state))) } #[derive(Deserialize)] struct HistoryQuery { thread_id: Option, limit: Option, before: 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; let limit = query.limit.unwrap_or(50); let before_cursor = query .before .as_deref() .map(|s| { chrono::DateTime::parse_from_rfc3339(s) .map(|dt| dt.with_timezone(&chrono::Utc)) .map_err(|_| { ( StatusCode::BAD_REQUEST, "Invalid 'before' timestamp".to_string(), ) }) }) .transpose()?; // 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()))? }; // Verify the thread belongs to the authenticated user before returning any data. // In-memory threads are already scoped by user via session_manager, but DB // lookups could expose another user's conversation if the UUID is guessed. if query.thread_id.is_some() { if let Some(ref store) = state.store { let owned = store .conversation_belongs_to_user(thread_id, &state.user_id) .await .unwrap_or(false); if !owned && !sess.threads.contains_key(&thread_id) { return Err((StatusCode::NOT_FOUND, "Thread not found".to_string())); } } } // For paginated requests (before cursor set), always go to DB if before_cursor.is_some() { if let Some(ref store) = state.store { let (messages, has_more) = store .list_conversation_messages_paginated(thread_id, before_cursor, limit as i64) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let oldest_timestamp = messages.first().map(|m| m.created_at.to_rfc3339()); let turns = build_turns_from_db_messages(&messages); return Ok(Json(HistoryResponse { thread_id, turns, has_more, oldest_timestamp, })); } } // Try in-memory first (freshest data for active threads) if let Some(thread) = sess.threads.get(&thread_id) { if !thread.turns.is_empty() { 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(); return Ok(Json(HistoryResponse { thread_id, turns, has_more: false, oldest_timestamp: None, })); } } // Fall back to DB for historical threads not in memory (paginated) if let Some(ref store) = state.store { let (messages, has_more) = store .list_conversation_messages_paginated(thread_id, None, limit as i64) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if !messages.is_empty() { let oldest_timestamp = messages.first().map(|m| m.created_at.to_rfc3339()); let turns = build_turns_from_db_messages(&messages); return Ok(Json(HistoryResponse { thread_id, turns, has_more, oldest_timestamp, })); } } // Empty thread (just created, no messages yet) Ok(Json(HistoryResponse { thread_id, turns: Vec::new(), has_more: false, oldest_timestamp: None, })) } /// Build TurnInfo pairs from flat DB messages (alternating user/assistant). fn build_turns_from_db_messages(messages: &[crate::history::ConversationMessage]) -> Vec { let mut turns = Vec::new(); let mut turn_number = 0; let mut iter = messages.iter().peekable(); while let Some(msg) = iter.next() { if msg.role == "user" { let mut turn = TurnInfo { turn_number, user_input: msg.content.clone(), response: None, state: "Completed".to_string(), started_at: msg.created_at.to_rfc3339(), completed_at: None, tool_calls: Vec::new(), }; // Check if next message is an assistant response if let Some(next) = iter.peek() { if next.role == "assistant" { let assistant_msg = iter.next().expect("peeked"); turn.response = Some(assistant_msg.content.clone()); turn.completed_at = Some(assistant_msg.created_at.to_rfc3339()); } } // Incomplete turn (user message without response) if turn.response.is_none() { turn.state = "Failed".to_string(); } turns.push(turn); turn_number += 1; } } 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; // Try DB first for persistent thread list if let Some(ref store) = state.store { // Auto-create assistant thread if it doesn't exist let assistant_id = store .get_or_create_assistant_conversation(&state.user_id, "gateway") .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if let Ok(summaries) = store .list_conversations_with_preview(&state.user_id, "gateway", 50) .await { let mut assistant_thread = None; let mut threads = Vec::new(); for s in &summaries { let info = ThreadInfo { id: s.id, state: "Idle".to_string(), turn_count: (s.message_count / 2).max(0) as usize, created_at: s.started_at.to_rfc3339(), updated_at: s.last_activity.to_rfc3339(), title: s.title.clone(), thread_type: s.thread_type.clone(), }; if s.id == assistant_id { assistant_thread = Some(info); } else { threads.push(info); } } // If assistant wasn't in the list (0 messages), synthesize it if assistant_thread.is_none() { assistant_thread = Some(ThreadInfo { id: assistant_id, state: "Idle".to_string(), turn_count: 0, created_at: chrono::Utc::now().to_rfc3339(), updated_at: chrono::Utc::now().to_rfc3339(), title: None, thread_type: Some("assistant".to_string()), }); } return Ok(Json(ThreadListResponse { assistant_thread, threads, active_thread: sess.active_thread, })); } } // Fallback: in-memory only (no assistant thread without DB) 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(), title: None, thread_type: None, }) .collect(); Ok(Json(ThreadListResponse { assistant_thread: None, 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(); let thread_id = thread.id; let info = 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(), title: None, thread_type: Some("thread".to_string()), }; // Persist the empty conversation row with thread_type metadata if let Some(ref store) = state.store { let store = Arc::clone(store); let user_id = state.user_id.clone(); tokio::spawn(async move { if let Err(e) = store .ensure_conversation(thread_id, "gateway", &user_id, None) .await { tracing::warn!("Failed to persist new thread: {}", e); } let metadata_val = serde_json::json!("thread"); if let Err(e) = store .update_conversation_metadata_field(thread_id, "thread_type", &metadata_val) .await { tracing::warn!("Failed to set thread_type metadata: {}", e); } }); } Ok(Json(info)) } // --- 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 store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), ))?; // Fetch sandbox jobs scoped to the authenticated user. let sandbox_jobs = store .list_sandbox_jobs_for_user(&state.user_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Scope jobs to the authenticated user. let mut jobs: Vec = sandbox_jobs .iter() .filter(|j| j.user_id == state.user_id) .map(|j| { let ui_state = match j.status.as_str() { "creating" => "pending", "running" => "in_progress", s => s, }; JobInfo { id: j.id, title: j.task.clone(), state: ui_state.to_string(), user_id: j.user_id.clone(), created_at: j.created_at.to_rfc3339(), started_at: j.started_at.map(|dt| dt.to_rfc3339()), } }) .collect(); // Most recent first. jobs.sort_by(|a, b| b.created_at.cmp(&a.created_at)); Ok(Json(JobListResponse { jobs })) } async fn jobs_summary_handler( State(state): State>, ) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), ))?; let s = store .sandbox_job_summary_for_user(&state.user_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(JobSummaryResponse { total: s.total, pending: s.creating, in_progress: s.running, completed: s.completed, failed: s.failed + s.interrupted, stuck: 0, })) } async fn jobs_detail_handler( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { let job_id = Uuid::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; // Try sandbox job from DB first, scoped to the authenticated user. if let Some(ref store) = state.store { if let Ok(Some(job)) = store.get_sandbox_job(job_id).await { if job.user_id != state.user_id { return Err((StatusCode::NOT_FOUND, "Job not found".to_string())); } let browse_id = std::path::Path::new(&job.project_dir) .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| job.id.to_string()); let ui_state = match job.status.as_str() { "creating" => "pending", "running" => "in_progress", s => s, }; let elapsed_secs = job.started_at.map(|start| { let end = job.completed_at.unwrap_or_else(chrono::Utc::now); (end - start).num_seconds().max(0) as u64 }); // Synthesize transitions from timestamps. let mut transitions = Vec::new(); if let Some(started) = job.started_at { transitions.push(TransitionInfo { from: "creating".to_string(), to: "running".to_string(), timestamp: started.to_rfc3339(), reason: None, }); } if let Some(completed) = job.completed_at { transitions.push(TransitionInfo { from: "running".to_string(), to: job.status.clone(), timestamp: completed.to_rfc3339(), reason: job.failure_reason.clone(), }); } return Ok(Json(JobDetailResponse { id: job.id, title: job.task.clone(), description: String::new(), state: ui_state.to_string(), user_id: job.user_id.clone(), created_at: job.created_at.to_rfc3339(), started_at: job.started_at.map(|dt| dt.to_rfc3339()), completed_at: job.completed_at.map(|dt| dt.to_rfc3339()), elapsed_secs, project_dir: Some(job.project_dir.clone()), browse_url: Some(format!("/projects/{}/", browse_id)), job_mode: { let mode = store.get_sandbox_job_mode(job.id).await.ok().flatten(); mode.filter(|m| m != "worker") }, transitions, })); } } Err((StatusCode::NOT_FOUND, "Job not found".to_string())) } async fn jobs_cancel_handler( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { let job_id = Uuid::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; // Try sandbox job cancellation, scoped to the authenticated user. if let Some(ref store) = state.store { if let Ok(Some(job)) = store.get_sandbox_job(job_id).await { if job.user_id != state.user_id { return Err((StatusCode::NOT_FOUND, "Job not found".to_string())); } if job.status == "running" || job.status == "creating" { // Stop the container if we have a job manager. if let Some(ref jm) = state.job_manager { if let Err(e) = jm.stop_job(job_id).await { tracing::warn!(job_id = %job_id, error = %e, "Failed to stop container during cancellation"); } } store .update_sandbox_job_status( job_id, "failed", Some(false), Some("Cancelled by user"), None, Some(chrono::Utc::now()), ) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; } return Ok(Json(serde_json::json!({ "status": "cancelled", "job_id": job_id, }))); } } Err((StatusCode::NOT_FOUND, "Job not found".to_string())) } async fn jobs_restart_handler( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), ))?; let jm = state.job_manager.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Sandbox not enabled".to_string(), ))?; let old_job_id = Uuid::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; let old_job = store .get_sandbox_job(old_job_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?; // Scope to the authenticated user. if old_job.user_id != state.user_id { return Err((StatusCode::NOT_FOUND, "Job not found".to_string())); } if old_job.status != "interrupted" && old_job.status != "failed" { return Err(( StatusCode::CONFLICT, format!("Cannot restart job in state '{}'", old_job.status), )); } // Create a new job with the same task and project_dir. let new_job_id = Uuid::new_v4(); let now = chrono::Utc::now(); let record = crate::history::SandboxJobRecord { id: new_job_id, task: old_job.task.clone(), status: "creating".to_string(), user_id: old_job.user_id.clone(), project_dir: old_job.project_dir.clone(), success: None, failure_reason: None, created_at: now, started_at: None, completed_at: None, }; store .save_sandbox_job(&record) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Look up the original job's mode so the restart uses the same mode. let mode = match store.get_sandbox_job_mode(old_job_id).await { Ok(Some(m)) if m == "claude_code" => crate::orchestrator::job_manager::JobMode::ClaudeCode, _ => crate::orchestrator::job_manager::JobMode::Worker, }; let project_dir = std::path::PathBuf::from(&old_job.project_dir); let _token = jm .create_job(new_job_id, &old_job.task, Some(project_dir), mode) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create container: {}", e), ) })?; store .update_sandbox_job_status(new_job_id, "running", None, None, Some(now), None) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(serde_json::json!({ "status": "restarted", "old_job_id": old_job_id, "new_job_id": new_job_id, }))) } // --- Claude Code prompt and events handlers --- /// Submit a follow-up prompt to a running Claude Code sandbox job. async fn jobs_prompt_handler( State(state): State>, Path(id): Path, Json(body): Json, ) -> Result, (StatusCode, String)> { let prompt_queue = state.prompt_queue.as_ref().ok_or(( StatusCode::NOT_IMPLEMENTED, "Claude Code not configured".to_string(), ))?; let job_id: uuid::Uuid = id .parse() .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; // Verify user owns this job. if let Some(ref store) = state.store { if !store .sandbox_job_belongs_to_user(job_id, &state.user_id) .await .unwrap_or(false) { return Err((StatusCode::NOT_FOUND, "Job not found".to_string())); } } let content = body .get("content") .and_then(|v| v.as_str()) .ok_or(( StatusCode::BAD_REQUEST, "Missing 'content' field".to_string(), ))? .to_string(); let done = body.get("done").and_then(|v| v.as_bool()).unwrap_or(false); let prompt = crate::orchestrator::api::PendingPrompt { content, done }; { let mut queue = prompt_queue.lock().await; queue.entry(job_id).or_default().push_back(prompt); } Ok(Json(serde_json::json!({ "status": "queued", "job_id": job_id.to_string(), }))) } /// Load persisted job events for a job (for history replay on page open). async fn jobs_events_handler( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::NOT_IMPLEMENTED, "Database not available".to_string(), ))?; let job_id: uuid::Uuid = id .parse() .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; // Verify user owns this job. if !store .sandbox_job_belongs_to_user(job_id, &state.user_id) .await .unwrap_or(false) { return Err((StatusCode::NOT_FOUND, "Job not found".to_string())); } let events = store .list_job_events(job_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let events_json: Vec = events .into_iter() .map(|e| { serde_json::json!({ "id": e.id, "event_type": e.event_type, "data": e.data, "created_at": e.created_at.to_rfc3339(), }) }) .collect(); Ok(Json(serde_json::json!({ "job_id": job_id.to_string(), "events": events_json, }))) } // --- Project file handlers for sandbox jobs --- #[derive(Deserialize)] struct FilePathQuery { path: Option, } async fn job_files_list_handler( State(state): State>, Path(id): Path, Query(query): Query, ) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), ))?; let job_id = Uuid::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; let job = store .get_sandbox_job(job_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?; // Verify user owns this job. if job.user_id != state.user_id { return Err((StatusCode::NOT_FOUND, "Job not found".to_string())); } let base = std::path::PathBuf::from(&job.project_dir); let rel_path = query.path.as_deref().unwrap_or(""); let target = base.join(rel_path); // Path traversal guard. let canonical = target .canonicalize() .map_err(|_| (StatusCode::NOT_FOUND, "Path not found".to_string()))?; let base_canonical = base .canonicalize() .map_err(|_| (StatusCode::NOT_FOUND, "Project dir not found".to_string()))?; if !canonical.starts_with(&base_canonical) { return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); } let mut entries = Vec::new(); let mut read_dir = tokio::fs::read_dir(&canonical) .await .map_err(|_| (StatusCode::NOT_FOUND, "Cannot read directory".to_string()))?; while let Ok(Some(entry)) = read_dir.next_entry().await { let name = entry.file_name().to_string_lossy().to_string(); let is_dir = entry .file_type() .await .map(|ft| ft.is_dir()) .unwrap_or(false); let rel = if rel_path.is_empty() { name.clone() } else { format!("{}/{}", rel_path, name) }; entries.push(ProjectFileEntry { name, path: rel, is_dir, }); } entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name))); Ok(Json(ProjectFilesResponse { entries })) } async fn job_files_read_handler( State(state): State>, Path(id): Path, Query(query): Query, ) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), ))?; let job_id = Uuid::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; let job = store .get_sandbox_job(job_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?; // Verify user owns this job. if job.user_id != state.user_id { return Err((StatusCode::NOT_FOUND, "Job not found".to_string())); } let path = query.path.as_deref().ok_or(( StatusCode::BAD_REQUEST, "path parameter required".to_string(), ))?; let base = std::path::PathBuf::from(&job.project_dir); let file_path = base.join(path); let canonical = file_path .canonicalize() .map_err(|_| (StatusCode::NOT_FOUND, "File not found".to_string()))?; let base_canonical = base .canonicalize() .map_err(|_| (StatusCode::NOT_FOUND, "Project dir not found".to_string()))?; if !canonical.starts_with(&base_canonical) { return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); } let content = tokio::fs::read_to_string(&canonical) .await .map_err(|_| (StatusCode::NOT_FOUND, "Cannot read file".to_string()))?; Ok(Json(ProjectFileReadResponse { path: path.to_string(), content, })) } // --- 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 )))), } } } } // --- Project file serving handlers --- /// Redirect `/projects/{id}` to `/projects/{id}/` so relative paths in /// the served HTML resolve within the project namespace. async fn project_redirect_handler(Path(project_id): Path) -> impl IntoResponse { axum::response::Redirect::permanent(&format!("/projects/{project_id}/")) } /// Serve `index.html` when hitting `/projects/{project_id}/`. async fn project_index_handler(Path(project_id): Path) -> impl IntoResponse { serve_project_file(&project_id, "index.html").await } /// Serve any file under `/projects/{project_id}/{path}`. async fn project_file_handler( Path((project_id, path)): Path<(String, String)>, ) -> impl IntoResponse { serve_project_file(&project_id, &path).await } /// Shared logic: resolve the file inside `~/.ironclaw/projects/{project_id}/`, /// guard against path traversal, and stream the content with the right MIME type. async fn serve_project_file(project_id: &str, path: &str) -> axum::response::Response { // Reject project_id values that could escape the projects directory. if project_id.contains('/') || project_id.contains('\\') || project_id.contains("..") || project_id.is_empty() { return (StatusCode::BAD_REQUEST, "Invalid project ID").into_response(); } let base = dirs::home_dir() .unwrap_or_else(|| std::path::PathBuf::from(".")) .join(".ironclaw") .join("projects") .join(project_id); let file_path = base.join(path); // Path traversal guard let canonical = match file_path.canonicalize() { Ok(p) => p, Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), }; let base_canonical = match base.canonicalize() { Ok(p) => p, Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), }; if !canonical.starts_with(&base_canonical) { return (StatusCode::FORBIDDEN, "Forbidden").into_response(); } match tokio::fs::read(&canonical).await { Ok(contents) => { let mime = mime_guess::from_path(&canonical) .first_or_octet_stream() .to_string(); ([(header::CONTENT_TYPE, mime)], contents).into_response() } Err(_) => (StatusCode::NOT_FOUND, "Not found").into_response(), } } 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()))), } } // --- Routines handlers --- async fn routines_list_handler( State(state): State>, ) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), ))?; let routines = store .list_routines(&state.user_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let items: Vec = routines.iter().map(routine_to_info).collect(); Ok(Json(RoutineListResponse { routines: items })) } async fn routines_summary_handler( State(state): State>, ) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), ))?; let routines = store .list_routines(&state.user_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let total = routines.len() as u64; let enabled = routines.iter().filter(|r| r.enabled).count() as u64; let disabled = total - enabled; let failing = routines .iter() .filter(|r| r.consecutive_failures > 0) .count() as u64; let today_start = chrono::Utc::now() .date_naive() .and_hms_opt(0, 0, 0) .map(|dt| dt.and_utc()); let runs_today = if let Some(start) = today_start { routines .iter() .filter(|r| r.last_run_at.is_some_and(|ts| ts >= start)) .count() as u64 } else { 0 }; Ok(Json(RoutineSummaryResponse { total, enabled, disabled, failing, runs_today, })) } async fn routines_detail_handler( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), ))?; let routine_id = Uuid::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?; let routine = store .get_routine(routine_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?; let runs = store .list_routine_runs(routine_id, 20) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let recent_runs: Vec = runs .iter() .map(|run| RoutineRunInfo { id: run.id, trigger_type: run.trigger_type.clone(), started_at: run.started_at.to_rfc3339(), completed_at: run.completed_at.map(|dt| dt.to_rfc3339()), status: format!("{:?}", run.status), result_summary: run.result_summary.clone(), tokens_used: run.tokens_used, }) .collect(); Ok(Json(RoutineDetailResponse { id: routine.id, name: routine.name.clone(), description: routine.description.clone(), enabled: routine.enabled, trigger: serde_json::to_value(&routine.trigger).unwrap_or_default(), action: serde_json::to_value(&routine.action).unwrap_or_default(), guardrails: serde_json::to_value(&routine.guardrails).unwrap_or_default(), notify: serde_json::to_value(&routine.notify).unwrap_or_default(), last_run_at: routine.last_run_at.map(|dt| dt.to_rfc3339()), next_fire_at: routine.next_fire_at.map(|dt| dt.to_rfc3339()), run_count: routine.run_count, consecutive_failures: routine.consecutive_failures, created_at: routine.created_at.to_rfc3339(), recent_runs, })) } async fn routines_trigger_handler( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), ))?; let routine_id = Uuid::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?; let routine = store .get_routine(routine_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?; // Send the routine prompt through the message pipeline as a manual trigger. let prompt = match &routine.action { crate::agent::routine::RoutineAction::Lightweight { prompt, .. } => prompt.clone(), crate::agent::routine::RoutineAction::FullJob { title, description, .. } => format!("{}: {}", title, description), }; let content = format!("[routine:{}] {}", routine.name, prompt); let msg = IncomingMessage::new("gateway", &state.user_id, content); 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(Json(serde_json::json!({ "status": "triggered", "routine_id": routine_id, }))) } #[derive(Deserialize)] struct ToggleRequest { enabled: Option, } async fn routines_toggle_handler( State(state): State>, Path(id): Path, body: Option>, ) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), ))?; let routine_id = Uuid::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?; let mut routine = store .get_routine(routine_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?; // If a specific value was provided, use it; otherwise toggle. routine.enabled = match body { Some(Json(req)) => req.enabled.unwrap_or(!routine.enabled), None => !routine.enabled, }; store .update_routine(&routine) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(serde_json::json!({ "status": if routine.enabled { "enabled" } else { "disabled" }, "routine_id": routine_id, }))) } async fn routines_delete_handler( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), ))?; let routine_id = Uuid::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?; let deleted = store .delete_routine(routine_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if deleted { Ok(Json(serde_json::json!({ "status": "deleted", "routine_id": routine_id, }))) } else { Err((StatusCode::NOT_FOUND, "Routine not found".to_string())) } } async fn routines_runs_handler( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), ))?; let routine_id = Uuid::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?; let runs = store .list_routine_runs(routine_id, 50) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let run_infos: Vec = runs .iter() .map(|run| RoutineRunInfo { id: run.id, trigger_type: run.trigger_type.clone(), started_at: run.started_at.to_rfc3339(), completed_at: run.completed_at.map(|dt| dt.to_rfc3339()), status: format!("{:?}", run.status), result_summary: run.result_summary.clone(), tokens_used: run.tokens_used, }) .collect(); Ok(Json(serde_json::json!({ "routine_id": routine_id, "runs": run_infos, }))) } /// Convert a Routine to the trimmed RoutineInfo for list display. fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo { let (trigger_type, trigger_summary) = match &r.trigger { crate::agent::routine::Trigger::Cron { schedule } => { ("cron".to_string(), format!("cron: {}", schedule)) } crate::agent::routine::Trigger::Event { pattern, channel, .. } => { let ch = channel.as_deref().unwrap_or("any"); ("event".to_string(), format!("on {} /{}/", ch, pattern)) } crate::agent::routine::Trigger::Webhook { path, .. } => { let p = path.as_deref().unwrap_or("/"); ("webhook".to_string(), format!("webhook: {}", p)) } crate::agent::routine::Trigger::Manual => ("manual".to_string(), "manual only".to_string()), }; let action_type = match &r.action { crate::agent::routine::RoutineAction::Lightweight { .. } => "lightweight", crate::agent::routine::RoutineAction::FullJob { .. } => "full_job", }; let status = if !r.enabled { "disabled" } else if r.consecutive_failures > 0 { "failing" } else { "active" }; RoutineInfo { id: r.id, name: r.name.clone(), description: r.description.clone(), enabled: r.enabled, trigger_type, trigger_summary, action_type: action_type.to_string(), last_run_at: r.last_run_at.map(|dt| dt.to_rfc3339()), next_fire_at: r.next_fire_at.map(|dt| dt.to_rfc3339()), run_count: r.run_count, consecutive_failures: r.consecutive_failures, status: status.to_string(), } } // --- Settings handlers --- async fn settings_list_handler( State(state): State>, ) -> Result, StatusCode> { let store = state .store .as_ref() .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; let rows = store.list_settings(&state.user_id).await.map_err(|e| { tracing::error!("Failed to list settings: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; let settings = rows .into_iter() .map(|r| SettingResponse { key: r.key, value: r.value, updated_at: r.updated_at.to_rfc3339(), }) .collect(); Ok(Json(SettingsListResponse { settings })) } async fn settings_get_handler( State(state): State>, Path(key): Path, ) -> Result, StatusCode> { let store = state .store .as_ref() .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; let row = store .get_setting_full(&state.user_id, &key) .await .map_err(|e| { tracing::error!("Failed to get setting '{}': {}", key, e); StatusCode::INTERNAL_SERVER_ERROR })? .ok_or(StatusCode::NOT_FOUND)?; Ok(Json(SettingResponse { key: row.key, value: row.value, updated_at: row.updated_at.to_rfc3339(), })) } async fn settings_set_handler( State(state): State>, Path(key): Path, Json(body): Json, ) -> Result { let store = state .store .as_ref() .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; store .set_setting(&state.user_id, &key, &body.value) .await .map_err(|e| { tracing::error!("Failed to set setting '{}': {}", key, e); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(StatusCode::NO_CONTENT) } async fn settings_delete_handler( State(state): State>, Path(key): Path, ) -> Result { let store = state .store .as_ref() .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; store .delete_setting(&state.user_id, &key) .await .map_err(|e| { tracing::error!("Failed to delete setting '{}': {}", key, e); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(StatusCode::NO_CONTENT) } async fn settings_export_handler( State(state): State>, ) -> Result, StatusCode> { let store = state .store .as_ref() .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; let settings = store.get_all_settings(&state.user_id).await.map_err(|e| { tracing::error!("Failed to export settings: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Json(SettingsExportResponse { settings })) } async fn settings_import_handler( State(state): State>, Json(body): Json, ) -> Result { let store = state .store .as_ref() .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; store .set_all_settings(&state.user_id, &body.settings) .await .map_err(|e| { tracing::error!("Failed to import settings: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(StatusCode::NO_CONTENT) } // --- 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, } #[cfg(test)] mod tests { use super::*; #[test] fn test_build_turns_from_db_messages_complete() { let now = chrono::Utc::now(); let messages = vec![ crate::history::ConversationMessage { id: Uuid::new_v4(), role: "user".to_string(), content: "Hello".to_string(), created_at: now, }, crate::history::ConversationMessage { id: Uuid::new_v4(), role: "assistant".to_string(), content: "Hi there!".to_string(), created_at: now + chrono::TimeDelta::seconds(1), }, crate::history::ConversationMessage { id: Uuid::new_v4(), role: "user".to_string(), content: "How are you?".to_string(), created_at: now + chrono::TimeDelta::seconds(2), }, crate::history::ConversationMessage { id: Uuid::new_v4(), role: "assistant".to_string(), content: "Doing well!".to_string(), created_at: now + chrono::TimeDelta::seconds(3), }, ]; let turns = build_turns_from_db_messages(&messages); assert_eq!(turns.len(), 2); assert_eq!(turns[0].user_input, "Hello"); assert_eq!(turns[0].response.as_deref(), Some("Hi there!")); assert_eq!(turns[0].state, "Completed"); assert_eq!(turns[1].user_input, "How are you?"); assert_eq!(turns[1].response.as_deref(), Some("Doing well!")); } #[test] fn test_build_turns_from_db_messages_incomplete_last() { let now = chrono::Utc::now(); let messages = vec![ crate::history::ConversationMessage { id: Uuid::new_v4(), role: "user".to_string(), content: "Hello".to_string(), created_at: now, }, crate::history::ConversationMessage { id: Uuid::new_v4(), role: "assistant".to_string(), content: "Hi!".to_string(), created_at: now + chrono::TimeDelta::seconds(1), }, crate::history::ConversationMessage { id: Uuid::new_v4(), role: "user".to_string(), content: "Lost message".to_string(), created_at: now + chrono::TimeDelta::seconds(2), }, ]; let turns = build_turns_from_db_messages(&messages); assert_eq!(turns.len(), 2); assert_eq!(turns[1].user_input, "Lost message"); assert!(turns[1].response.is_none()); assert_eq!(turns[1].state, "Failed"); } #[test] fn test_build_turns_from_db_messages_empty() { let turns = build_turns_from_db_messages(&[]); assert!(turns.is_empty()); } }