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;
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));
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());
if let Some(pw) = web_cfg.password {
let auth_state = Arc::new(auth::AuthState::new(web_cfg.username.clone(), pw));
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 {
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);
}
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)
}
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_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;
}
}
})
}
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(),
});
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(),
});
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 { .. } => {
let agents = state.swarm_agents.read().await;
let final_count = agents.len();
drop(agents);
let _ = final_count; }
AgentEvent::Done { .. } => {
}
_ => {}
}
}
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}…")
}
}