axocoatl-server 0.1.0

Axum HTTP/WebSocket API server for Axocoatl
pub mod auth;
pub mod middleware;
pub mod routes;

use std::sync::Arc;

use axocoatl_daemon::AxocoatlDaemon;
use axum::{
    routing::{get, post},
    Router,
};
use tokio::sync::RwLock;

/// Shared application state for the Axum server.
pub type AppState = Arc<RwLock<AxocoatlDaemon>>;

/// Build the Axum router with all API routes.
pub fn build_router(state: AppState) -> Router {
    Router::new()
        .route("/", get(routes::dashboard))
        .route("/lattice/{file}", get(routes::lattice_asset))
        .route("/vendor/{*file}", get(routes::vendor_asset))
        .route("/health", get(routes::health))
        .route("/health/ready", get(routes::health_ready))
        .route("/health/live", get(routes::health_live))
        .route("/api/llm-health", get(routes::llm_health))
        .route("/api/agents", get(routes::list_agents))
        .route(
            "/api/agents/{agent_id}/execute",
            post(routes::execute_agent),
        )
        .route("/api/agents/{agent_id}/status", get(routes::agent_status))
        .route(
            "/api/agents/{agent_id}/restart",
            post(routes::restart_agent),
        )
        .route(
            "/api/agents/{agent_id}",
            axum::routing::patch(routes::patch_agent),
        )
        .route("/api/mcp/catalog", get(routes::mcp_catalog))
        .route("/api/mcp/install", post(routes::install_mcp))
        .route(
            "/api/mcp/servers/{name}",
            post(routes::reconnect_mcp).delete(routes::remove_mcp),
        )
        .route("/api/mcp/servers", get(routes::list_mcp_servers))
        .route(
            "/api/mcp/permissions",
            get(routes::list_mcp_permissions).delete(routes::revoke_mcp_permission),
        )
        .route("/api/mcp/tools", get(routes::list_mcp_tools))
        .route("/api/schedules", get(routes::list_schedules))
        .route("/api/proactive", get(routes::list_proactive))
        .route(
            "/api/schedules/{id}",
            axum::routing::patch(routes::patch_schedule),
        )
        .route("/api/schedules/{id}/run", post(routes::run_schedule))
        .route("/api/skills", get(routes::list_skills))
        .route("/api/skills/{id}/fire", post(routes::fire_skill))
        .route("/api/events/recent", get(routes::recent_events))
        .route("/api/workflows", get(routes::list_workflows))
        .route(
            "/api/workflows/{workflow_id}/execute",
            post(routes::execute_workflow),
        )
        .route("/api/tokens/report", get(routes::token_report))
        .route(
            "/api/sessions",
            get(routes::list_sessions).post(routes::create_session),
        )
        .route("/api/sessions/{id}/execute", post(routes::execute_session))
        .route("/api/sessions/{id}/tree", get(routes::session_tree))
        .route(
            "/api/sessions/{id}/file",
            get(routes::session_file).post(routes::session_file_write),
        )
        .route(
            "/api/sessions/{id}/tasks",
            get(routes::session_tasks).post(routes::session_task_spawn),
        )
        .route(
            "/api/sessions/{id}/terminals/{tid}/ws",
            get(routes::session_terminal_ws),
        )
        .route(
            "/api/sessions/{id}/proxy/{port}",
            get(routes::session_browser_proxy_root),
        )
        .route(
            "/api/sessions/{id}/proxy/{port}/{*tail}",
            get(routes::session_browser_proxy),
        )
        .route("/axo-tap.js", get(routes::axo_tap_script))
        .route("/brand/{file}", get(routes::brand_asset))
        .route(
            "/api/automations",
            get(routes::list_automations).post(routes::create_automation),
        )
        .route(
            "/api/automations/{id}",
            get(routes::get_automation)
                .put(routes::update_automation)
                .patch(routes::update_automation)
                .delete(routes::delete_automation),
        )
        .route("/api/automations/{id}/run", post(routes::run_automation))
        .route("/api/automations/{id}/move", post(routes::move_automation))
        .route(
            "/api/automation-folders",
            get(routes::list_automation_folders)
                .post(routes::create_automation_folder)
                .patch(routes::rename_automation_folder)
                .delete(routes::delete_automation_folder),
        )
        .route("/api/automations/{id}/runs", get(routes::list_runs))
        .route("/api/automations/{id}/runs/{run_id}", get(routes::get_run))
        .route(
            "/api/automations/{id}/runs/{run_id}/fork",
            post(routes::fork_run),
        )
        .route("/api/tools", get(routes::list_tools))
        .route("/api/interrupts", get(routes::list_interrupts))
        .route(
            "/api/automations/{id}/runs/{run_id}/nodes/{node_id}/resume",
            post(routes::resume_interrupt),
        )
        .route(
            "/api/automations/{id}/runs/{run_id}/nodes/{node_id}/cancel",
            post(routes::cancel_interrupt),
        )
        .route(
            "/api/sessions/{id}",
            axum::routing::delete(routes::close_session).patch(routes::rename_session),
        )
        // ── Chats ── lightweight conversations, no directory/sandbox.
        .route(
            "/api/chat",
            get(routes::list_chats).post(routes::create_chat),
        )
        .route(
            "/api/chat/{id}",
            get(routes::get_chat)
                .patch(routes::patch_chat)
                .delete(routes::delete_chat),
        )
        .route("/api/chat/{id}/fork", post(routes::fork_chat))
        .route("/api/chat/{id}/export", get(routes::export_chat))
        .route(
            "/api/chat/{id}/attach",
            post(routes::upload_chat_attachment).put(routes::attach_file_to_chat),
        )
        .route(
            "/api/chat/{id}/attach/{file_id}",
            get(routes::get_chat_attachment)
                .delete(routes::delete_chat_attachment)
                .patch(routes::pin_chat_attachment),
        )
        // Cross-chat Files browser surface.
        .route(
            "/api/files",
            get(routes::list_files).post(routes::upload_file),
        )
        .route(
            "/api/files/{id}",
            get(routes::get_file_meta)
                .patch(routes::patch_file)
                .delete(routes::delete_file),
        )
        .route("/api/files/{id}/bytes", get(routes::get_file_bytes))
        .route("/api/llm/models", get(routes::list_models))
        .route("/api/fs/list", get(routes::fs_list_dirs))
        .route("/api/fs/project", get(routes::fs_project_probe))
        .route("/ws", get(routes::ws))
        .layer(axum::middleware::from_fn(middleware::request_logging))
        .layer(middleware::cors_headers())
        .with_state(state)
}

/// Start the HTTP server.
pub async fn serve(daemon: AxocoatlDaemon, host: &str, port: u16) -> std::io::Result<()> {
    let state: AppState = Arc::new(RwLock::new(daemon));
    serve_shared(state, host, port).await
}

/// Start the HTTP server with a shared daemon state (for use alongside IPC).
pub async fn serve_shared(state: AppState, host: &str, port: u16) -> std::io::Result<()> {
    let app = build_router(state);

    let addr = format!("{host}:{port}");
    tracing::info!(addr = %addr, "Starting Axocoatl API server");

    let listener = tokio::net::TcpListener::bind(&addr).await?;
    axum::serve(listener, app).await?;
    Ok(())
}