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 machine's own state, over HTTP.
//!
//! What belongs to no worktree: which machine this is, what is waiting
//! between it and its peers, and adding a checkout outside `$PRJ` by its
//! path. The projects and worktrees themselves are `/api/v1/projects`
//! (`handlers::prj`); everything one worktree holds lives under
//! `/api/v1/worktrees/{id}/` and is served by that worktree's own router.

use std::sync::Arc;

use axum::{
    extract::State,
    routing::{get, post},
    Json, Router,
};
use serde::{Deserialize, Serialize};

use crate::machine::MachineState;

pub fn routes() -> Router<Arc<MachineState>> {
    Router::new()
        .route("/api/v1/machine", get(machine))
        .route("/api/v1/machine/conflicts", get(conflicts))
        .route("/api/v1/worktrees", post(add))
}

/// This machine, as the page's chrome shows it.
#[derive(Debug, Serialize)]
pub struct MachineView {
    pub id: String,
    pub name: String,
    pub fingerprint: String,
    pub version: String,
    pub peer_port: u16,
    pub started_at: u64,
}

async fn machine(State(machine): State<Arc<MachineState>>) -> Json<MachineView> {
    Json(MachineView {
        id: machine.identity.id().to_string(),
        name: machine.identity.name().to_string(),
        fingerprint: machine.identity.fingerprint().to_string(),
        version: wsb::get_version().to_string(),
        peer_port: machine.peers.peer_port(),
        started_at: machine.started_at,
    })
}

/// Every unresolved conflict on this machine, counted by where it is.
///
/// Read by the workbench chrome rather than by a page about one relationship:
/// "something between two machines is waiting for me" has to be visible from
/// wherever you are, and a count nobody sees until they open the right peer is
/// a decision that never gets made.
#[derive(Debug, Serialize)]
pub struct PeerCensus {
    pub total: usize,
    pub workspaces: Vec<WorkspacePeerState>,
}

#[derive(Debug, Serialize)]
pub struct WorkspacePeerState {
    pub workspace: String,
    pub display_name: String,
    pub total: usize,
    pub repositories: Vec<RepositoryPeerState>,
}

#[derive(Debug, Serialize)]
pub struct RepositoryPeerState {
    /// Workspace-relative; the superproject is the empty string, exactly as
    /// the tree addresses it.
    pub repository: String,
    pub conflicts: usize,
    /// Paths waiting to come ONTO this machine, summed over peers.
    ///
    /// Separate from `outgoing` because they are different facts about
    /// different machines and a person acts on them differently: what is
    /// coming here is this machine's to take, and what would leave is the
    /// peer's to accept. One "pending" number could say neither.
    pub incoming: usize,
    /// Paths waiting to go TO a peer, summed over peers.
    pub outgoing: usize,
    /// Which machines this repository has anything pending WITH, by name. A
    /// count alone cannot be acted on — the decision is per peer.
    pub peers: Vec<String>,
}

async fn conflicts(State(machine): State<Arc<MachineState>>) -> Json<PeerCensus> {
    let counts = machine.store.conflict_counts().unwrap_or_default();
    let names: std::collections::BTreeMap<String, String> = machine
        .store
        .peers()
        .unwrap_or_default()
        .into_iter()
        .map(|peer| (peer.id.to_string(), peer.name))
        .collect();
    // A continuity id names a worktree by its family and name; the page names
    // it as the projects list does.
    let titles: std::collections::BTreeMap<String, String> = machine
        .store
        .worktrees()
        .unwrap_or_default()
        .into_iter()
        .filter_map(|worktree| {
            let title = worktree.display_name();
            worktree.workspace.map(|workspace| (workspace.to_string(), title))
        })
        .collect();

    // Grouped here rather than in SQL: the shape the page reads is a tree, and
    // three ordered scalars are what a GROUP BY can return.
    let mut workspaces: Vec<WorkspacePeerState> = Vec::new();
    for count in counts {
        let workspace = match workspaces
            .iter_mut()
            .find(|held| held.workspace == count.workspace)
        {
            Some(held) => held,
            None => {
                workspaces.push(WorkspacePeerState {
                    display_name: titles
                        .get(&count.workspace)
                        .cloned()
                        // A conflict recorded against a workspace this machine
                        // no longer registers is still a conflict; named by its
                        // id rather than dropped.
                        .unwrap_or_else(|| count.workspace.clone()),
                    workspace: count.workspace.clone(),
                    total: 0,
                    repositories: Vec::new(),
                });
                workspaces.last_mut().expect("just pushed")
            }
        };
        workspace.total += count.conflicts;
        let peer = names
            .get(&count.peer)
            .cloned()
            .unwrap_or_else(|| count.peer.clone());
        match workspace
            .repositories
            .iter_mut()
            .find(|held| held.repository == count.repository)
        {
            Some(held) => {
                held.conflicts += count.conflicts;
                if !held.peers.contains(&peer) {
                    held.peers.push(peer);
                }
            }
            None => workspace.repositories.push(RepositoryPeerState {
                repository: count.repository.clone(),
                conflicts: count.conflicts,
                incoming: 0,
                outgoing: 0,
                peers: vec![peer],
            }),
        }
    }

    // What is waiting to MOVE, from the last reconciliation of every
    // relationship. Conflicts are persisted — they outlive the process that
    // found them — while pending transfers are observed state and live only
    // for as long as this daemon runs, so the two come from different places
    // and are merged here rather than in either of them.
    //
    // A repository with nothing in conflict but files waiting to cross must
    // still appear, which is why this can add rows rather than only filling in
    // the ones conflicts created.
    for (peer, workspace_id, statuses) in machine.observed_statuses() {
        let peer_name = names.get(&peer).cloned().unwrap_or(peer);
        let workspace = match workspaces
            .iter_mut()
            .find(|held| held.workspace == workspace_id)
        {
            Some(held) => held,
            None => {
                workspaces.push(WorkspacePeerState {
                    display_name: titles
                        .get(&workspace_id)
                        .cloned()
                        .unwrap_or_else(|| workspace_id.clone()),
                    workspace: workspace_id.clone(),
                    total: 0,
                    repositories: Vec::new(),
                });
                workspaces.last_mut().expect("just pushed")
            }
        };
        for status in statuses {
            if status.incoming == 0 && status.outgoing == 0 {
                continue;
            }
            let repository = status.repository.to_string();
            match workspace
                .repositories
                .iter_mut()
                .find(|held| held.repository == repository)
            {
                Some(held) => {
                    held.incoming += status.incoming;
                    held.outgoing += status.outgoing;
                    if !held.peers.contains(&peer_name) {
                        held.peers.push(peer_name.clone());
                    }
                }
                None => workspace.repositories.push(RepositoryPeerState {
                    repository,
                    conflicts: 0,
                    incoming: status.incoming,
                    outgoing: status.outgoing,
                    peers: vec![peer_name.clone()],
                }),
            }
        }
    }

    // Read by a person, so ordered the way they read it: by name.
    for workspace in &mut workspaces {
        workspace.repositories.sort_by(|a, b| a.repository.cmp(&b.repository));
        for repository in &mut workspace.repositories {
            repository.peers.sort();
        }
    }

    Json(PeerCensus {
        total: workspaces.iter().map(|workspace| workspace.total).sum(),
        workspaces,
    })
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AddRequest {
    /// A directory anywhere inside the checkout. Resolved to its super root
    /// the same way every command does.
    pub path: String,
}

/// Open a checkout by its path — how one outside `$PRJ` joins the list.
async fn add(
    State(machine): State<Arc<MachineState>>,
    Json(body): Json<AddRequest>,
) -> Result<Json<wsb::daemon::WorktreeInfo>, (axum::http::StatusCode, Json<serde_json::Value>)> {
    let runtime = machine
        .resolve(std::path::Path::new(&body.path))
        .await
        .map_err(|error| {
            (
                axum::http::StatusCode::BAD_REQUEST,
                Json(serde_json::json!({ "error": error })),
            )
        })?;
    crate::web::spawn_worktree_services_once(&runtime);
    Ok(Json(crate::control::worktree_info(&runtime)))
}

#[cfg(test)]
#[path = "machine_tests.rs"]
mod tests;