wsb-daemon 0.258.6441

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
//! The workbench's machine-wide routes: what belongs to the machine and to no
//! worktree.
//!
//! Agent sessions and their analytics, stars, pins and trash, the agents'
//! plans, and the host strip. Each was served under every worktree from that
//! worktree's own copy — N copies of the same sessions, N sync engines and N
//! transcript watchers. Here there is one, and a question about one project or
//! one worktree is a filter on it (`?project=`, `?worktree=`), not a copy.

use std::path::PathBuf;
use std::sync::Arc;

use axum::{
    http::StatusCode,
    routing::{delete, get, patch, post, put},
    Json, Router,
};
use wsb::persist::global::models::PlaceFilter;
use wsb::persist::global::SessionRepo;

use super::handlers;
use super::handlers::sessions::ErrorResponse;
use super::sync::SyncHandle;

/// What the machine routes are served from.
#[derive(Clone)]
pub struct MachineWeb {
    pub db: Arc<SessionRepo>,
    pub sync: SyncHandle,
    /// Which volume the host strip's disk figure is about: the one `$PRJ` is
    /// on, which is the one every worktree's builds fill — or the machine
    /// directory's, on a machine with no `$PRJ`.
    pub volume_root: PathBuf,
}

impl MachineWeb {
    /// Open the machine's sessions and start the one sync engine.
    ///
    /// Must run inside the daemon's runtime: the engine is a task on it.
    pub fn open(machine_dir: &std::path::Path) -> anyhow::Result<Self> {
        let prj = match wsb_prj::Prj::from_env() {
            Ok(prj) => Some(prj),
            Err(wsb_prj::PrjError::NotSet) => None,
            Err(error) => return Err(anyhow::anyhow!(error)),
        };
        let db = Arc::new(SessionRepo::open_machine(machine_dir)?);
        let volume_root = prj
            .as_ref()
            .map(|prj| prj.root().to_path_buf())
            .unwrap_or_else(|| machine_dir.to_path_buf());
        let sync = super::sync::spawn_sync_actor(db.clone(), prj);
        Ok(Self {
            db,
            sync,
            volume_root,
        })
    }

    /// Bring the sessions up to date with the transcripts, then follow them.
    ///
    /// Once per daemon, at startup: the machine's sessions do not depend on
    /// which worktree is open.
    pub fn spawn_services(&self) {
        let sync = self.sync.clone();
        tokio::spawn(async move {
            if let Err(error) = sync.sync_incremental().await {
                wsb::errln!("[wsb] initial session sync failed: {error:#}");
            }
            if let Err(error) = super::sync::start_watcher(sync).await {
                wsb::errln!("[wsb] the transcript watcher stopped: {error:#}");
            }
        });
    }
}

/// The filter a request's `project`, `worktree` and `placed` ask for, or a
/// 400 saying why they do not make one.
pub fn place_filter(
    project: Option<String>,
    worktree: Option<String>,
    placed: Option<String>,
) -> Result<PlaceFilter, (StatusCode, Json<ErrorResponse>)> {
    let placed = match placed.as_deref() {
        None | Some("false") => false,
        Some("true") => true,
        Some(other) => {
            return Err((
                StatusCode::BAD_REQUEST,
                Json(ErrorResponse {
                    error: format!("placed is true or false, not {other:?}"),
                }),
            ))
        }
    };
    PlaceFilter::new(project, worktree, placed)
        .map_err(|error| (StatusCode::BAD_REQUEST, Json(ErrorResponse { error })))
}

/// Every machine route, under `/api/v1`, ready to merge into a router of any
/// state.
pub fn routes<S: Clone + Send + Sync + 'static>(state: MachineWeb) -> Router<S> {
    let api = Router::new()
        // Sessions
        .route("/sessions", get(handlers::sessions::list_sessions))
        .route("/sessions/places", get(handlers::sessions::list_places))
        .route("/sessions/:id", get(handlers::sessions::get_session))
        .route("/sessions/:id/messages", get(handlers::sessions::get_messages))
        .route("/sessions/:id/children", get(handlers::sessions::get_children))
        .route("/sessions/:id/minimap", get(handlers::sessions::get_minimap))
        .route("/sessions/:id/search", get(handlers::sessions::search_session))
        .route("/sessions/:id/compact", get(handlers::sessions::get_compact_text))
        .route("/sessions/:id/export", get(handlers::sessions::export_session))
        .route("/sessions/:id/watch", get(handlers::sessions::watch_session))
        .route("/sessions/:id/rename", patch(handlers::sessions::rename_session))
        .route("/sessions/:id", delete(handlers::sessions::delete_session))
        .route("/sessions/:id/restore", post(handlers::sessions::restore_session))
        .route("/sessions/:id/permanent", delete(handlers::sessions::permanent_delete))
        .route("/machines", get(handlers::sessions::list_machines))
        .route("/agents", get(handlers::sessions::list_agents))
        .route("/stats", get(handlers::sessions::get_stats))
        // Analytics
        .route("/analytics/summary", get(handlers::analytics::summary))
        .route("/analytics/activity", get(handlers::analytics::activity))
        .route("/analytics/heatmap", get(handlers::analytics::heatmap))
        .route("/analytics/projects", get(handlers::analytics::projects))
        .route("/analytics/hour-of-week", get(handlers::analytics::hour_of_week))
        .route("/analytics/top-sessions", get(handlers::analytics::top_sessions))
        // Usage
        .route("/usage/summary", get(handlers::analytics::usage_summary))
        .route("/usage/daily", get(handlers::analytics::usage_daily))
        .route("/usage/by-model", get(handlers::analytics::usage_by_model))
        .route("/usage/by-project", get(handlers::analytics::usage_by_project))
        .route("/usage/sync-pricing", post(handlers::analytics::sync_pricing))
        // Search, over every session's messages.
        .route("/search", get(handlers::search::session_search))
        // Sync
        .route("/sync", post(handlers::sync_handler::trigger_sync))
        .route("/resync", post(handlers::sync_handler::trigger_resync))
        .route("/sync/status", get(handlers::sync_handler::sync_status))
        // Starred
        .route("/starred", get(handlers::starred::list_starred))
        .route("/sessions/:id/star", put(handlers::starred::star_session))
        .route("/sessions/:id/star", delete(handlers::starred::unstar_session))
        .route("/starred/bulk", post(handlers::starred::bulk_star))
        // Pins
        .route("/pins", get(handlers::pins::list_all_pins))
        .route("/sessions/:id/pins", get(handlers::pins::list_session_pins))
        .route(
            "/sessions/:id/messages/:message_id/pin",
            post(handlers::pins::pin_message),
        )
        .route(
            "/sessions/:id/messages/:message_id/pin",
            delete(handlers::pins::unpin_message),
        )
        // Trash
        .route("/trash", get(handlers::trash::list_trash))
        .route("/trash", delete(handlers::trash::empty_trash))
        // The agents' plans.
        .route("/plans/list", get(handlers::plans::list_plans))
        .route("/plans/content", get(handlers::plans::get_plan_content))
        // The host itself: one reading, and a stream of them for the strip
        // in the title bar.
        .route("/host", get(handlers::host::host_snapshot))
        .route("/host/stream", get(handlers::host::host_stream));

    Router::new().nest("/api/v1", api).with_state(state)
}