decision_cockpit 0.1.0

Layer — product decision memory with MCP tools and an embedded review dashboard
Documentation
//! HTTP API for the UI. Thin handlers over the shared service layer.

pub mod candidates;
pub mod decisions;
pub mod documents;
pub mod drift;
pub mod entities;
pub mod graph;
pub mod memos;
pub mod relations;

use std::io;

use axum::http::{HeaderValue, Method};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde_json::json;
use tokio::task::JoinHandle;
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;

use crate::assets;
use crate::config::Config;
use crate::state::AppState;

/// Build the full application router for all phases.
pub fn router(state: AppState, config: &Config) -> Router {
    let api = Router::new()
        .route("/health", get(health))
        // Documents
        .route("/documents", post(documents::create).get(documents::list))
        .route("/documents/:id", get(documents::get_one))
        .route("/documents/:id/status", post(documents::set_status))
        .route(
            "/documents/:id/candidates",
            get(candidates::list_for_document),
        )
        // Candidates
        .route("/candidates", get(candidates::list))
        .route("/candidates/:id/accept", post(candidates::accept))
        .route("/candidates/:id/reject", post(candidates::reject))
        // Decisions + graph
        .route("/decisions", get(decisions::list))
        .route("/decisions/:id", get(decisions::get_one))
        .route("/graph", get(graph::get_graph))
        // Canonical entity lists (for linking)
        .route("/assumptions", get(entities::list_assumptions))
        .route("/actions", get(entities::list_actions))
        .route("/evidence", get(entities::list_evidence))
        // Relations
        .route("/relations", post(relations::create))
        // Drift signals
        .route("/drift-signals", get(drift::list))
        .route("/drift-signals/:id/accept", post(drift::accept))
        .route("/drift-signals/:id/dismiss", post(drift::dismiss))
        // Memos
        .route("/memos", get(memos::list).post(memos::create))
        .route("/memos/:id", get(memos::get_one).put(memos::update))
        .route(
            "/memos/from-drift/:drift_signal_id",
            post(memos::from_drift),
        )
        .layer(TraceLayer::new_for_http())
        .layer(build_cors(config))
        .with_state(state);

    if config.serve_dashboard {
        attach_dashboard(api, config)
    } else {
        api
    }
}

/// Start the HTTP server on a background task.
pub fn spawn(state: AppState, config: &Config) -> JoinHandle<()> {
    let bind_addr = config.api_bind_addr.clone();
    let app = router(state, config);

    tokio::spawn(async move {
        match tokio::net::TcpListener::bind(&bind_addr).await {
            Ok(listener) => {
                tracing::info!(addr = %bind_addr, "HTTP API + dashboard listening");
                if let Err(e) = axum::serve(listener, app).await {
                    tracing::error!(error = %e, "HTTP server stopped");
                }
            }
            Err(e) if e.kind() == io::ErrorKind::AddrInUse => {
                tracing::info!(
                    addr = %bind_addr,
                    "port already in use — reusing existing HTTP server"
                );
            }
            Err(e) => {
                tracing::error!(addr = %bind_addr, error = %e, "failed to bind HTTP server");
            }
        }
    })
}

fn attach_dashboard(router: Router, config: &Config) -> Router {
    // Dev override: serve from disk when COCKPIT_FRONTEND_DIST is set.
    if let Some(dist) = &config.frontend_dist_override {
        let index = dist.join("index.html");
        if index.exists() {
            tracing::info!(path = %dist.display(), "serving dashboard from disk override");
            let serve_dir = ServeDir::new(dist).not_found_service(ServeFile::new(index));
            return router.fallback_service(serve_dir);
        }
        tracing::warn!(
            path = %dist.display(),
            "COCKPIT_FRONTEND_DIST set but index.html missing — using embedded dashboard"
        );
    }

    tracing::info!("serving embedded dashboard");
    router.fallback(get(assets::serve_dashboard))
}

async fn health() -> Json<serde_json::Value> {
    Json(json!({ "status": "ok" }))
}

fn build_cors(config: &Config) -> CorsLayer {
    let methods = [Method::GET, Method::POST, Method::PUT, Method::OPTIONS];

    if config
        .cors_allowed_origins
        .iter()
        .any(|o| o == "*")
        || config.cors_allowed_origins.is_empty()
    {
        return CorsLayer::new()
            .allow_origin(Any)
            .allow_methods(methods)
            .allow_headers(Any);
    }

    let origins: Vec<HeaderValue> = config
        .cors_allowed_origins
        .iter()
        .filter_map(|o| o.parse::<HeaderValue>().ok())
        .collect();

    CorsLayer::new()
        .allow_origin(origins)
        .allow_methods(methods)
        .allow_headers(Any)
}