collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! Web server module for collet (feature-gated behind `web`).

mod auth;
mod event;
mod routes;
mod routes_files;
mod routes_projects;
mod routes_search;
mod routes_sessions;
mod routes_sidebar;
mod routes_swarm;
mod routes_symbols;
mod sse;
mod state;
mod static_files;

pub use event::WebEvent;
pub use state::AppState;

use std::net::SocketAddr;
use std::sync::Arc;

use axum::Router;
use axum::routing::post;
use tokio::sync::{broadcast, mpsc};
use tower_http::cors::{AllowOrigin, Any, CorsLayer};
use tower_http::trace::TraceLayer;

use crate::agent::r#loop::AgentEvent;
use crate::api::provider::OpenAiCompatibleProvider;
use crate::config::{Config, WebConfig};
use crate::server::routes_swarm::SwarmAgentNode;

/// Start the web server.
pub async fn start(
    config: Config,
    client: OpenAiCompatibleProvider,
    event_bus: broadcast::Sender<WebEvent>,
    bind: SocketAddr,
    working_dir: String,
    web_cfg: WebConfig,
) -> anyhow::Result<tokio::task::JoinHandle<()>> {
    let has_password = web_cfg.password.is_some();
    let state = Arc::new(AppState::new(config, client, event_bus, working_dir));

    // CORS
    let cors = if web_cfg.cors_origins.is_empty() {
        CorsLayer::new()
            .allow_origin(Any)
            .allow_methods(Any)
            .allow_headers(Any)
    } else {
        let origins: Vec<_> = web_cfg
            .cors_origins
            .iter()
            .filter_map(|o| o.parse().ok())
            .collect();
        CorsLayer::new()
            .allow_origin(AllowOrigin::list(origins))
            .allow_methods(Any)
            .allow_headers(Any)
    };

    let mut app = Router::new()
        .merge(routes::router())
        .merge(sse::router())
        .merge(routes_sessions::router())
        .merge(routes_files::router())
        .merge(routes_projects::router())
        .merge(routes_search::router())
        .merge(routes_sidebar::router())
        .merge(routes_swarm::router())
        .merge(routes_symbols::router());

    // Auth: only add middleware + login route when password is configured
    if let Some(pw) = web_cfg.password {
        let auth_state = Arc::new(auth::AuthState::new(web_cfg.username.clone(), pw));

        // Auth status endpoint (public — tells client if auth is needed)
        let auth_status = {
            let resp = auth::AuthStatusResponse {
                auth_required: true,
            };
            axum::routing::get(move || async move { axum::response::Json(resp) })
        };

        app = app
            .route(
                "/api/auth/login",
                post(auth::login).with_state(auth_state.clone()),
            )
            .route("/api/auth/status", auth_status)
            .layer(axum::middleware::from_fn_with_state(
                auth_state,
                auth::require_auth,
            ));
    } else {
        // No password — add status endpoint saying auth is not required
        let auth_status = {
            let resp = auth::AuthStatusResponse {
                auth_required: false,
            };
            axum::routing::get(move || async move { axum::response::Json(resp) })
        };
        app = app.route("/api/auth/status", auth_status);
    }

    // Static files: serve embedded Svelte build, fallback to index.html for SPA
    app = app.fallback(static_files::serve);

    let app = app
        .layer(cors)
        .layer(TraceLayer::new_for_http())
        .with_state(state);

    let listener = tokio::net::TcpListener::bind(bind).await?;
    tracing::info!(%bind, auth = has_password, "Web server listening");

    let handle = tokio::spawn(async move {
        if let Err(e) = axum::serve(listener, app).await {
            tracing::error!("Web server error: {e}");
        }
    });

    Ok(handle)
}

/// Bridge: forward AgentEvents from mpsc to broadcast as WebEvents.
///
/// Also updates `AppState.swarm_agents` in real-time so the graph
/// visualization API always reflects the latest state.
pub fn spawn_event_bridge(
    mut rx: mpsc::UnboundedReceiver<AgentEvent>,
    bus: broadcast::Sender<WebEvent>,
    state: std::sync::Arc<AppState>,
) -> tokio::task::JoinHandle<()> {
    tokio::spawn(async move {
        while let Some(ev) = rx.recv().await {
            // Update swarm agent graph state before broadcasting
            update_swarm_state(&state, &ev).await;

            let web_ev = WebEvent::from_agent_event(&ev);
            let is_terminal = matches!(ev, AgentEvent::Done { .. } | AgentEvent::SwarmDone { .. });
            let _ = bus.send(web_ev);
            if is_terminal {
                break;
            }
        }
    })
}

/// Update the in-memory swarm agent graph from agent events.
async fn update_swarm_state(state: &AppState, ev: &AgentEvent) {
    use crate::server::routes_swarm::AgentLogEntry;
    let now = || {
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_millis() as u64
    };

    match ev {
        AgentEvent::SwarmAgentStarted {
            agent_id,
            agent_name,
            task_preview,
        } => {
            let mut agents = state.swarm_agents.write().await;
            agents.insert(
                agent_id.clone(),
                SwarmAgentNode {
                    id: agent_id.clone(),
                    name: agent_name.clone(),
                    task: task_preview.clone(),
                    status: "running".to_string(),
                    success: false,
                    iteration: 0,
                    tool_calls: 0,
                    input_tokens: 0,
                    output_tokens: 0,
                    dependencies: Vec::new(),
                    target_files: Vec::new(),
                    modified_files: Vec::new(),
                    log: vec![AgentLogEntry::Status {
                        message: "started".to_string(),
                        timestamp: now(),
                    }],
                },
            );
        }
        AgentEvent::SwarmAgentProgress {
            agent_id,
            iteration,
            status,
            ..
        } => {
            let mut agents = state.swarm_agents.write().await;
            if let Some(agent) = agents.get_mut(agent_id) {
                agent.iteration = *iteration;
                agent.status = status.clone();
                agent.log.push(AgentLogEntry::Status {
                    message: format!("iteration #{iteration}: {status}"),
                    timestamp: now(),
                });
                // Keep log bounded
                if agent.log.len() > 500 {
                    let drain = agent.log.len() - 400;
                    agent.log.drain(..drain);
                }
            }
        }
        AgentEvent::SwarmAgentToolCall {
            agent_id,
            name,
            args,
        } => {
            let mut agents = state.swarm_agents.write().await;
            if let Some(agent) = agents.get_mut(agent_id) {
                agent.tool_calls += 1;
                agent.log.push(AgentLogEntry::ToolCall {
                    name: name.clone(),
                    args: truncate_for_log(args, 500),
                    timestamp: now(),
                });
            }
        }
        AgentEvent::SwarmAgentToolResult {
            agent_id,
            name,
            result,
            success,
        } => {
            let mut agents = state.swarm_agents.write().await;
            if let Some(agent) = agents.get_mut(agent_id) {
                agent.log.push(AgentLogEntry::ToolResult {
                    name: name.clone(),
                    result: truncate_for_log(result, 500),
                    success: *success,
                    timestamp: now(),
                });
            }
        }
        AgentEvent::SwarmAgentToken { agent_id, text } => {
            let mut agents = state.swarm_agents.write().await;
            if let Some(agent) = agents.get_mut(agent_id) {
                agent.log.push(AgentLogEntry::Token {
                    text: text.clone(),
                    timestamp: now(),
                });
                // Tokens are high-frequency; keep log bounded
                if agent.log.len() > 500 {
                    let drain = agent.log.len() - 400;
                    agent.log.drain(..drain);
                }
            }
        }
        AgentEvent::SwarmAgentResponse { agent_id, text } => {
            let mut agents = state.swarm_agents.write().await;
            if let Some(agent) = agents.get_mut(agent_id) {
                agent.log.push(AgentLogEntry::Response {
                    text: truncate_for_log(text, 1000),
                    timestamp: now(),
                });
            }
        }
        AgentEvent::SwarmAgentDone {
            agent_id,
            success,
            modified_files,
            tool_calls,
            input_tokens,
            output_tokens,
            response,
            ..
        } => {
            let mut agents = state.swarm_agents.write().await;
            if let Some(agent) = agents.get_mut(agent_id) {
                agent.status = "done".to_string();
                agent.success = *success;
                agent.modified_files = modified_files.clone();
                agent.tool_calls = *tool_calls;
                agent.input_tokens = *input_tokens;
                agent.output_tokens = *output_tokens;
                agent.log.push(AgentLogEntry::Response {
                    text: truncate_for_log(response, 1000),
                    timestamp: now(),
                });
            }
        }
        AgentEvent::SwarmDone { .. } => {
            // Clear agents after a short delay so the final state is visible
            let agents = state.swarm_agents.read().await;
            let final_count = agents.len();
            drop(agents);
            // Don't clear immediately — the graph should show the final state
            // It will be cleared on the next SwarmAgentStarted
            let _ = final_count; // suppress unused warning
        }
        AgentEvent::Done { .. } => {
            // Single-agent done — clear swarm state
            // (deferred to avoid clearing graph too early)
        }
        _ => {}
    }
}

fn truncate_for_log(s: &str, max: usize) -> String {
    if s.len() <= max {
        s.to_string()
    } else {
        let truncated: String = s.chars().take(max).collect();
        format!("{truncated}")
    }
}