wsb-daemon 0.291.8591

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the host
//! Who is using this workshop, and what of GitHub they have in view.
//!
//! The daemon keeps GitHub current only for what somebody is using: a daemon
//! nobody is using asks GitHub nothing, so the hosts a person is not at cost
//! the account nothing. Two kinds of lease say what is in use:
//!
//! * A page's: a visible workbench page says what it shows — the worktree it
//!   works in, the repository pages, boards, threads and gists open — when it
//!   loads, whenever that changes, and every minute while it stays visible. A
//!   page that is hidden, closed or gone holds nothing: its lease is dropped
//!   when it says it is hidden, and lapses when it stops saying anything.
//! * A command's: a command run in a worktree holds that worktree in view for
//!   a while after, as a page would.
//!
//! Every change wakes the keeper, so what comes into view is caught up at
//! once.

use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use serde::Deserialize;
use tokio::sync::Notify;
use wsb_github::boards::BoardRef;
use wsb_github::sync::Demand;

/// How long a page's word holds without being said again. A visible page
/// repeats it every minute.
pub const PAGE_LEASE: Duration = Duration::from_secs(150);

/// How long a command holds its worktree in view.
pub const COMMAND_LEASE: Duration = Duration::from_secs(10 * 60);

/// The least time between two refreshes asked by one page. A second press
/// within it asks nothing: what it would read is what was just read.
pub const REFRESH_AT_MOST: Duration = Duration::from_secs(20);

/// One thing a page shows, in the workbench's own terms.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Subject {
    /// The worktree the page works in: its repositories, their counts and
    /// CI, as the explorer shows them.
    Worktree { id: String },
    /// A repository's page; `traffic` while its traffic is shown.
    Repo { repo: String, traffic: bool },
    /// The boards home.
    Boards,
    Board { owner: String, number: i64 },
    /// An issue's or pull request's thread.
    Issue { repo: String, number: i64 },
    Gists,
}

/// What a page said it shows.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Presence {
    /// The page's own id, the one its event streams carry.
    pub client: String,
    pub visible: bool,
    #[serde(default)]
    pub subjects: Vec<Subject>,
}

#[derive(Debug, Clone)]
struct PageLease {
    until: Instant,
    shows: Demand,
}

#[derive(Debug, Default)]
struct Leases {
    pages: BTreeMap<String, PageLease>,
    commands: BTreeMap<PathBuf, Instant>,
    /// When each page last refreshed.
    refreshed: BTreeMap<String, Instant>,
}

/// The leases, and the keeper's wake-up.
#[derive(Debug, Default)]
pub struct Attention {
    leases: Mutex<Leases>,
    wake: Arc<Notify>,
}

impl Attention {
    pub fn new() -> Self {
        Self::default()
    }

    /// What the keeper waits on while nothing is in use.
    pub fn wake(&self) -> Arc<Notify> {
        self.wake.clone()
    }

    fn leases(&self) -> std::sync::MutexGuard<'_, Leases> {
        // A panic while holding the leases leaves nothing half-written that
        // matters: the worst is a lease that lapses.
        self.leases.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
    }

    /// A page said what it `shows`, or that it is hidden.
    pub fn page(&self, client: &str, visible: bool, shows: Demand, now: Instant) {
        let changed = {
            let mut leases = self.leases();
            if visible {
                let before = leases.pages.get(client).map(|lease| lease.shows.clone());
                leases.pages.insert(client.to_string(), PageLease { until: now + PAGE_LEASE, shows: shows.clone() });
                before.as_ref() != Some(&shows)
            } else {
                leases.pages.remove(client).is_some()
            }
        };
        if changed {
            self.wake.notify_one();
        }
    }

    /// A command ran in `checkout`.
    pub fn command(&self, checkout: PathBuf, now: Instant) {
        let fresh = self.leases().commands.insert(checkout, now + COMMAND_LEASE).is_none_or(|until| until <= now);
        if fresh {
            self.wake.notify_one();
        }
    }

    /// Everything in use at `now`: the union of every live lease. Lapsed
    /// leases are dropped on the way.
    pub fn demand(&self, now: Instant) -> Demand {
        let mut leases = self.leases();
        leases.pages.retain(|_, lease| lease.until > now);
        leases.commands.retain(|_, until| *until > now);
        let mut demand = Demand::default();
        for lease in leases.pages.values() {
            merge(&mut demand, &lease.shows);
            // A person at a visible page sees its notifications.
            demand.notifications = true;
        }
        demand.checkouts.extend(leases.commands.keys().cloned());
        demand
    }

    /// Whether `client` may refresh now: not when it did within
    /// [`REFRESH_AT_MOST`]. Saying yes records the refresh.
    pub fn may_refresh(&self, client: &str, now: Instant) -> bool {
        let mut leases = self.leases();
        leases.refreshed.retain(|_, at| now.duration_since(*at) < REFRESH_AT_MOST);
        if leases.refreshed.contains_key(client) {
            return false;
        }
        leases.refreshed.insert(client.to_string(), now);
        true
    }
}

fn merge(into: &mut Demand, from: &Demand) {
    into.checkouts.extend(from.checkouts.iter().cloned());
    into.repositories.extend(from.repositories.iter().cloned());
    into.traffic.extend(from.traffic.iter().cloned());
    into.boards_home |= from.boards_home;
    into.boards.extend(from.boards.iter().cloned());
    into.threads.extend(from.threads.iter().cloned());
    into.gists |= from.gists;
}

/// What a page's subjects ask for, its worktrees found by `checkout_of`.
/// A worktree this daemon cannot find is refused by name, never dropped.
pub fn demand_of_subjects(
    subjects: &[Subject],
    checkout_of: &BTreeMap<String, PathBuf>,
) -> Result<Demand, String> {
    let mut demand = Demand::default();
    for subject in subjects {
        match subject {
            Subject::Worktree { id } => {
                let path = checkout_of.get(id).ok_or_else(|| format!("no worktree {id} is open here"))?;
                demand.checkouts.insert(path.clone());
            }
            Subject::Repo { repo, traffic } => {
                if repo.split('/').count() != 2 || repo.split('/').any(str::is_empty) {
                    return Err(format!("{repo:?} is not owner/repo"));
                }
                demand.repositories.insert(repo.clone());
                if *traffic {
                    demand.traffic.insert(repo.clone());
                }
            }
            Subject::Boards => demand.boards_home = true,
            Subject::Board { owner, number } => {
                demand.boards.insert(BoardRef { owner: owner.clone(), number: *number });
            }
            Subject::Issue { repo, number } => {
                demand.threads.insert((repo.clone(), *number));
            }
            Subject::Gists => demand.gists = true,
        }
    }
    Ok(demand)
}

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