use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
use tokio::sync::RwLock;
use wsb::daemon::{Continuity, RegisteredWorktree, WorktreeId, WorktreePlace};
use wsb::peer::identity::MachineIdentity;
use wsb::peer::model::WorkspaceId;
use wsb::peer::protocol::WorkspaceSummary;
use wsb::peer::scan::Policy;
use crate::peer::coordinator::LocalWorkspace;
use crate::peer::service::{now, PeerService, DEFAULT_PEER_PORT};
use crate::peer::store::MachineStore;
use crate::web::state::WebState;
pub struct WorktreeRuntime {
pub registered: RegisteredWorktree,
pub web: WebState,
router: std::sync::Mutex<axum::Router>,
pub local: Result<Arc<LocalWorkspace>, String>,
}
impl WorktreeRuntime {
pub fn router(&self) -> axum::Router {
self.router
.lock()
.expect("a worktree router is unusable after a panic")
.clone()
}
}
pub struct MachineState {
pub identity: Arc<MachineIdentity>,
pub store: Arc<MachineStore>,
pub peers: Arc<PeerService>,
pub machine_dir: PathBuf,
pub listen: std::net::IpAddr,
pub port: u16,
pub started_at: u64,
pub control_token: String,
worktrees: RwLock<BTreeMap<WorktreeId, Arc<WorktreeRuntime>>>,
observed: std::sync::Mutex<
BTreeMap<(String, String), Vec<wsb::peer::model::RepositoryStatus>>,
>,
plans: std::sync::Mutex<BTreeMap<(String, String, String), Vec<PlannedMove>>>,
pub nudge: Arc<crate::peer::watch::Nudge>,
registering: tokio::sync::Mutex<BTreeMap<WorktreeId, Arc<tokio::sync::Mutex<()>>>>,
watching: std::sync::Mutex<Vec<PathBuf>>,
pub github: Result<Arc<crate::web::github::GitHub>, String>,
pub plugins: Arc<crate::plugin::PluginRegistry>,
pub web: crate::web::machine_web::MachineWeb,
pub listing: Arc<crate::web::handlers::prj::Listing>,
}
fn same_directory(one: &Path, other: &Path) -> bool {
match (wsb::platform::canonicalize(&one), wsb::platform::canonicalize(&other)) {
(Ok(one), Ok(other)) => one == other,
_ => one == other,
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct PlannedMove {
pub path: String,
pub direction: &'static str,
pub size: Option<u64>,
pub decision: Option<&'static str>,
pub dismissed: bool,
pub here_hash: Option<String>,
pub there_hash: Option<String>,
}
impl MachineState {
pub fn new(
machine_dir: PathBuf,
listen: std::net::IpAddr,
port: u16,
peer_listen: std::net::IpAddr,
peer_port: u16,
) -> Result<Arc<Self>> {
let identity = Arc::new(
MachineIdentity::load_or_create(&machine_dir, &default_machine_name())
.map_err(|e| anyhow::anyhow!(e))?,
);
let store = Arc::new(MachineStore::open(&machine_dir).map_err(|e| anyhow::anyhow!(e))?);
let peers = Arc::new(PeerService::new(
identity.clone(),
store.clone(),
peer_listen,
if peer_port == 0 { DEFAULT_PEER_PORT } else { peer_port },
));
let plugins = Arc::new(crate::plugin::PluginRegistry::new(machine_dir.clone()));
let web = crate::web::machine_web::MachineWeb::open(&machine_dir)?;
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)),
};
Ok(Arc::new(Self {
identity,
store,
peers,
machine_dir,
listen,
port,
started_at: now(),
control_token: wsb::daemon::new_control_token(),
worktrees: RwLock::new(BTreeMap::new()),
observed: std::sync::Mutex::new(BTreeMap::new()),
plans: std::sync::Mutex::new(BTreeMap::new()),
registering: tokio::sync::Mutex::new(BTreeMap::new()),
nudge: Arc::new(crate::peer::watch::Nudge::default()),
watching: std::sync::Mutex::new(Vec::new()),
github: crate::web::github::GitHub::open(),
plugins,
web,
listing: Arc::new(crate::web::handlers::prj::Listing::new(prj)),
}))
}
pub fn note_projects_changed(&self) {
self.listing.want();
}
pub async fn all(&self) -> Vec<Arc<WorktreeRuntime>> {
self.worktrees.read().await.values().cloned().collect()
}
pub async fn get(&self, id: &WorktreeId) -> Option<Arc<WorktreeRuntime>> {
self.worktrees.read().await.get(id).cloned()
}
pub async fn continuity(
&self,
workspace: &WorkspaceId,
) -> Result<Option<(Arc<WorktreeRuntime>, Arc<LocalWorkspace>)>, String> {
let carrying: Vec<Arc<WorktreeRuntime>> = self
.all()
.await
.into_iter()
.filter(|runtime| runtime.registered.workspace.as_ref() == Some(workspace))
.collect();
match carrying.as_slice() {
[] => Ok(None),
[one] => {
let local = one.local.clone().map_err(|why| {
format!("{} has no continuity: {why}", one.registered.display_name())
})?;
Ok(Some((one.clone(), local)))
}
several => Err(ambiguous(workspace, several)),
}
}
pub async fn open(&self, id: &WorktreeId) -> Result<Arc<WorktreeRuntime>, String> {
if let Some(open) = self.get(id).await {
return Ok(open);
}
let root = match id.in_project() {
Some((project, worktree)) => {
let prj = wsb_prj::Prj::from_env().map_err(|e| e.to_string())?;
prj.project(project)
.map_err(|e| e.to_string())?
.dir()
.join(wsb_prj::worktree_folder(worktree))
}
None => self
.store
.worktrees()?
.into_iter()
.find(|recorded| &recorded.id == id)
.map(|recorded| recorded.super_root)
.ok_or_else(|| format!("this machine has never opened a worktree {id}"))?,
};
if !root.is_dir() {
return Err(format!("worktree {id} is not there: {} does not exist", root.display()));
}
let runtime = self.register(&root).await?;
if &runtime.registered.id != id {
return Err(format!(
"{} is worktree {}, not {id}",
root.display(),
runtime.registered.id
));
}
Ok(runtime)
}
pub async fn resolve(&self, cwd: &Path) -> Result<Arc<WorktreeRuntime>, String> {
let root = wsb::workspace::find_workspace_root(cwd).ok_or_else(|| {
format!(
"{} is not inside a wsb worktree: no repository above it holds .wsb/state.json \
(`wsb init` makes one)",
cwd.display()
)
})?;
self.register(&root).await
}
async fn register(&self, root: &Path) -> Result<Arc<WorktreeRuntime>, String> {
let super_root = wsb::workspace::find_super_root(root).ok_or_else(|| {
format!(
"{} is not inside a git repository, so it is not part of any worktree",
root.display()
)
})?;
if !same_directory(&super_root, root) {
return Err(format!(
"{} is not a worktree root: it is inside the worktree at {}",
root.display(),
super_root.display()
));
}
let place = wsb::daemon::place_worktree(root)?;
if let Some(existing) = self.touch(&place.id).await {
return Ok(existing);
}
let gate = {
let mut gates = self.registering.lock().await;
gates.entry(place.id.clone()).or_default().clone()
};
let _building = gate.lock().await;
if let Some(existing) = self.touch(&place.id).await {
return Ok(existing);
}
let continuity = wsb::daemon::identify_workspace(&place)?;
let id = place.id.clone();
let opened_at = place.root.clone();
let runtime = self
.build(place, continuity)
.await
.map_err(|e| format!("cannot open the worktree at {}: {e:#}", opened_at.display()))?;
let runtime = Arc::new(runtime);
self.store.register_worktree(&runtime.registered)?;
self.worktrees.write().await.insert(id, runtime.clone());
self.watch_worktrees().await;
self.note_projects_changed();
Ok(runtime)
}
async fn touch(&self, id: &WorktreeId) -> Option<Arc<WorktreeRuntime>> {
let existing = self.get(id).await?;
let mut registered = existing.registered.clone();
registered.last_seen = now();
if let Err(error) = self.store.register_worktree(®istered) {
tracing::warn!(%error, worktree = %id, "cannot record that the worktree was used");
}
Some(existing)
}
async fn build(&self, place: WorktreePlace, continuity: Continuity) -> Result<WorktreeRuntime> {
let root = place.root.clone();
let opening = root.clone();
let ctx = tokio::task::spawn_blocking(move || -> Result<wsb::app::AppContext> {
crate::web::validate_gitmodules_if_present(&opening)?;
wsb::workspace::exclude_state_dir(&opening)
.with_context(|| format!("cannot exclude {}/.wsb from git", opening.display()))?;
wsb::app::AppContext::bootstrap(&opening)
.with_context(|| format!("cannot open {}", opening.display()))
})
.await
.context("the task opening the worktree did not finish")??;
let sessions_here = place.project.clone().map(|project| wsb::persist::global::models::PlaceFilter {
project: Some(project),
worktree: Some(place.name.clone()),
placed: false,
});
let web = WebState::new(
ctx.db.clone(),
ctx.db_service.clone(),
self.web.db.clone(),
sessions_here,
root.clone(),
self.listen,
self.port,
self.github.clone(),
)
.await?;
crate::web::log_janitor::spawn(web.clone());
let local = match continuity.workspace.clone() {
Ok(workspace) => {
let walking = root.clone();
tokio::task::spawn_blocking(move || {
LocalWorkspace::discover(workspace, &walking, continuity_policy(&walking)?)
.map(Arc::new)
})
.await
.context("the task reading the repository tree did not finish")?
}
Err(why) => Err(why),
};
if let (Ok(_), Err(why)) = (&continuity.workspace, &local) {
tracing::warn!(worktree = %place.id, error = %why, "continuity is off for this worktree");
}
Ok(WorktreeRuntime {
registered: RegisteredWorktree {
id: place.id,
project: place.project,
name: place.name,
workspace: continuity.workspace.ok(),
super_root: root,
root_commit: continuity.root_commit,
last_seen: now(),
available: true,
},
router: std::sync::Mutex::new(crate::web::create_router(web.clone())),
web,
local,
})
}
pub fn forget_contained_worktrees(&self) -> Result<Vec<PathBuf>, String> {
let registered = self.store.worktrees()?;
let mut dropped = Vec::new();
for candidate in ®istered {
let contained = registered.iter().any(|other| {
other.id != candidate.id
&& !same_directory(&other.super_root, &candidate.super_root)
&& candidate.super_root.starts_with(&other.super_root)
});
if contained {
self.store.forget_worktree(&candidate.id)?;
dropped.push(candidate.super_root.clone());
}
}
Ok(dropped)
}
async fn watch_worktrees(&self) {
let roots: Vec<PathBuf> = self
.worktrees
.read()
.await
.values()
.filter(|runtime| runtime.local.is_ok())
.map(|runtime| runtime.registered.super_root.clone())
.collect();
{
let watching = self.watching.lock().expect("the watch set lock is poisoned");
if *watching == roots {
return;
}
}
match crate::peer::watch::spawn(roots.clone(), self.nudge.clone()) {
Ok(count) => {
*self.watching.lock().expect("the watch set lock is poisoned") = roots;
tracing::debug!(roots = count, "watching for working-tree changes");
}
Err(error) => {
tracing::warn!(%error, "continuity will reconcile on its sweep alone");
}
}
}
pub async fn refresh_tree(&self, workspace: &WorkspaceId) -> Result<(), String> {
let Some((runtime, _)) = self.continuity(workspace).await? else {
return Ok(());
};
let local = Arc::new(LocalWorkspace::discover(
workspace.clone(),
&runtime.registered.super_root,
continuity_policy(&runtime.registered.super_root)?,
)?);
let mut worktrees = self.worktrees.write().await;
if let Some(current) = worktrees.get(&runtime.registered.id) {
let replacement = Arc::new(WorktreeRuntime {
registered: current.registered.clone(),
web: current.web.clone(),
router: std::sync::Mutex::new(current.router()),
local: Ok(local),
});
worktrees.insert(runtime.registered.id.clone(), replacement);
}
Ok(())
}
pub async fn summaries(&self) -> Vec<WorkspaceSummary> {
let mut by_workspace: BTreeMap<WorkspaceId, Vec<Arc<WorktreeRuntime>>> = BTreeMap::new();
for runtime in self.all().await {
if runtime.local.is_err() {
continue;
}
if let Some(workspace) = runtime.registered.workspace.clone() {
by_workspace.entry(workspace).or_default().push(runtime);
}
}
let mut summaries = Vec::new();
for (workspace, runtimes) in by_workspace {
match runtimes.as_slice() {
[one] => match &one.registered.root_commit {
Some(root_commit) => summaries.push(WorkspaceSummary {
workspace,
display_name: one.registered.display_name(),
root_commit: root_commit.clone(),
}),
None => tracing::error!(
worktree = %one.registered.id,
"a worktree with a continuity id has no root commit"
),
},
several => tracing::warn!(error = %ambiguous(&workspace, several), "not advertised"),
}
}
summaries
}
pub fn staging_root(&self) -> PathBuf {
self.machine_dir.join("staging")
}
pub fn record_statuses(
&self,
peer: &wsb::peer::model::PeerId,
workspace: &WorkspaceId,
statuses: &[wsb::peer::model::RepositoryStatus],
) {
for status in statuses {
if let Err(error) = status.check() {
tracing::error!(%error, "a reconciliation produced an ill-formed status");
return;
}
}
if let Ok(mut observed) = self.observed.lock() {
observed.insert(
(peer.to_string(), workspace.to_string()),
statuses.to_vec(),
);
}
}
pub fn record_plan(
&self,
peer: &wsb::peer::model::PeerId,
workspace: &WorkspaceId,
repository: &wsb::peer::model::RepositoryId,
moves: Vec<PlannedMove>,
) {
if let Ok(mut plans) = self.plans.lock() {
let key = (
peer.to_string(),
workspace.to_string(),
repository.as_str().to_string(),
);
plans.insert(key, moves);
}
}
pub fn plans(
&self,
peer: &wsb::peer::model::PeerId,
workspace: &WorkspaceId,
) -> BTreeMap<String, Vec<PlannedMove>> {
let prefix = (peer.to_string(), workspace.to_string());
self.plans
.lock()
.ok()
.map(|plans| {
plans
.iter()
.filter(|((p, w, _), _)| p == &prefix.0 && w == &prefix.1)
.map(|((_, _, repository), moves)| (repository.clone(), moves.clone()))
.collect()
})
.unwrap_or_default()
}
pub fn forget_peer(&self, peer: &wsb::peer::model::PeerId) {
let peer = peer.to_string();
if let Ok(mut observed) = self.observed.lock() {
observed.retain(|(p, _), _| p != &peer);
}
if let Ok(mut plans) = self.plans.lock() {
plans.retain(|(p, _, _), _| p != &peer);
}
}
pub fn observed_statuses(
&self,
) -> Vec<(String, String, Vec<wsb::peer::model::RepositoryStatus>)> {
self.observed
.lock()
.map(|observed| {
observed
.iter()
.map(|((peer, workspace), statuses)| {
(peer.clone(), workspace.clone(), statuses.clone())
})
.collect()
})
.unwrap_or_default()
}
pub fn statuses(
&self,
peer: &wsb::peer::model::PeerId,
workspace: &WorkspaceId,
) -> Vec<wsb::peer::model::RepositoryStatus> {
self.observed
.lock()
.ok()
.and_then(|observed| observed.get(&(peer.to_string(), workspace.to_string())).cloned())
.unwrap_or_default()
}
pub async fn peer_inventory(
self: &Arc<Self>,
peer: &wsb::peer::model::PeerId,
) -> Result<Vec<WorkspaceSummary>, String> {
let machine = self.clone();
let peer = peer.clone();
tokio::task::spawn_blocking(move || {
let mut session = machine.peers.connect(&peer)?;
let answer: wsb::peer::protocol::Response =
session.exchange(&wsb::peer::protocol::Request::Inventory)?;
session.shutdown();
match answer {
wsb::peer::protocol::Response::Inventory { workspaces } => Ok(workspaces),
other => Err(format!("the peer answered an inventory with {other:?}")),
}
})
.await
.map_err(|e| format!("join error: {e}"))?
}
}
pub struct BlockingMachine {
pub state: Arc<MachineState>,
}
impl crate::peer::connection::Machine for BlockingMachine {
fn workspaces(&self) -> Vec<WorkspaceSummary> {
let state = self.state.clone();
futures::executor::block_on(async move { state.summaries().await })
}
fn workspace(&self, id: &WorkspaceId) -> Result<Arc<LocalWorkspace>, String> {
let state = self.state.clone();
let id = id.clone();
futures::executor::block_on(async move { state.continuity(&id).await })?
.map(|(_, local)| local)
.ok_or_else(|| "this machine does not have that worktree open".to_string())
}
fn store(&self) -> Arc<MachineStore> {
self.state.store.clone()
}
fn staging_root(&self) -> PathBuf {
self.state.staging_root()
}
fn plugins(&self) -> Arc<crate::plugin::PluginRegistry> {
self.state.plugins.clone()
}
}
fn ambiguous(workspace: &WorkspaceId, runtimes: &[Arc<WorktreeRuntime>]) -> String {
let places: Vec<String> = runtimes
.iter()
.map(|runtime| format!("{} ({})", runtime.registered.display_name(), runtime.registered.super_root.display()))
.collect();
format!(
"{workspace} is claimed by {} worktrees on this machine: {}. Continuity carries one \
worktree with one; rename one of them (or keep one closed) so each name is unique",
runtimes.len(),
places.join(", ")
)
}
#[derive(Debug, Default, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct PeerConfig {
#[serde(default)]
continuity: Policy,
}
pub fn continuity_policy(root: &Path) -> Result<Policy, String> {
let path = root.join(wsb::peer::POLICY_FILE);
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Policy::default()),
Err(error) => return Err(format!("cannot read {}: {error}", path.display())),
};
serde_json::from_str::<PeerConfig>(&text)
.map(|config| config.continuity)
.map_err(|error| format!("{} is not a valid peer configuration: {error}", path.display()))
}
fn default_machine_name() -> String {
if let Ok(name) = std::env::var("WSB_MACHINE_NAME") {
if !name.trim().is_empty() {
return name.trim().to_string();
}
}
hostname().unwrap_or_else(|| "this machine".to_string())
}
fn hostname() -> Option<String> {
#[cfg(unix)]
{
let output = wsb::git_backend::command_without_repo_scope("hostname")
.output()
.ok()?;
let name = String::from_utf8(output.stdout).ok()?;
let name = name.trim().split('.').next()?.to_string();
(!name.is_empty()).then_some(name)
}
#[cfg(windows)]
{
std::env::var("COMPUTERNAME").ok().filter(|n| !n.is_empty())
}
}
#[cfg(test)]
#[path = "machine_tests.rs"]
mod tests;