use std::collections::BTreeSet;
use std::path::Path;
use std::sync::Arc;
use wsb::peer::compatibility::{eligibility, RepositoryFacts};
use wsb::peer::converge::{self, Action};
use wsb::peer::model::{
Blocked, Continuity, Desired, Observed, PeerId, RepositoryId, RepositoryStatus, Service,
WorkspaceId,
};
use wsb::peer::protocol::{
baseline_digest, RefusalCode, RepositoryReport, Request, Response, Write,
};
use wsb::peer::scan::{self, Policy};
use wsb::peer::transport::Session;
use super::service::{now, PeerService};
use super::store::MachineStore;
pub struct LocalWorkspace {
pub id: WorkspaceId,
pub repositories: Vec<LocalRepository>,
pub policy: Policy,
}
pub struct LocalRepository {
pub id: RepositoryId,
pub workdir: std::path::PathBuf,
pub git_dir: std::path::PathBuf,
}
impl LocalWorkspace {
pub fn discover(
id: WorkspaceId,
root: &Path,
policy: Policy,
) -> Result<Self, String> {
let tree = wsb::fleet::git::SubmoduleTree::discover_allow_empty(root)
.map_err(|e| format!("cannot read the repository tree at {}: {e}", root.display()))?;
let mut repositories = vec![LocalRepository {
id: RepositoryId::root(),
workdir: root.to_path_buf(),
git_dir: root.join(".git"),
}];
for node in tree.top_down() {
if !node.initialized {
continue;
}
let Ok(repository) = RepositoryId::parse(&node.display_path) else {
continue;
};
repositories.push(LocalRepository {
id: repository,
git_dir: resolve_git_dir(&node.abs_path),
workdir: node.abs_path.clone(),
});
}
Ok(Self {
id,
repositories,
policy,
})
}
pub fn repository(&self, id: &RepositoryId) -> Option<&LocalRepository> {
self.repositories.iter().find(|repo| &repo.id == id)
}
pub fn report(&self, wanted: &[RepositoryId]) -> Vec<RepositoryReport> {
wanted
.iter()
.map(|id| match self.repository(id) {
Some(repo) => {
let facts = RepositoryFacts::read(id.clone(), &repo.workdir, &repo.git_dir)
.unwrap_or_else(|_| RepositoryFacts::absent(id.clone()));
let entries = if facts.present {
scan::read(&repo.workdir, &self.policy).unwrap_or_default()
} else {
Default::default()
};
RepositoryReport {
facts,
policy: (&self.policy).into(),
entries,
}
}
None => RepositoryReport {
facts: RepositoryFacts::absent(id.clone()),
policy: (&self.policy).into(),
entries: Default::default(),
},
})
.collect()
}
}
fn resolve_git_dir(workdir: &Path) -> std::path::PathBuf {
let marker = workdir.join(".git");
let Ok(text) = std::fs::read_to_string(&marker) else {
return marker;
};
let Some(rest) = text.trim().strip_prefix("gitdir:") else {
return marker;
};
let target = std::path::PathBuf::from(rest.trim());
if target.is_absolute() {
target
} else {
workdir.join(target)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outcome {
pub statuses: Vec<RepositoryStatus>,
pub plans: Vec<(RepositoryId, Vec<crate::machine::PlannedMove>)>,
}
pub fn reconcile(
service: &PeerService,
store: &Arc<MachineStore>,
session: &mut Session,
peer: &PeerId,
local: &LocalWorkspace,
staging_root: &Path,
) -> Result<Outcome, String> {
let desired = store.desired(peer, &local.id)?;
let continuity = Continuity::of(
store.service(peer, Service::Files)?,
desired,
store.mode(peer, &local.id)?,
);
if !continuity.compares() {
return Ok(Outcome {
statuses: local
.repositories
.iter()
.map(|repo| RepositoryStatus {
repository: repo.id.clone(),
desired,
continuity,
observed: Observed::Idle,
blocked: None,
pending: 0,
incoming: 0,
outgoing: 0,
conflicts: 0,
undecided: 0,
branch: None,
head: None,
})
.collect(),
plans: Vec::new(),
});
}
let wanted: Vec<RepositoryId> = local.repositories.iter().map(|r| r.id.clone()).collect();
let agreed_when_asked: std::collections::BTreeMap<RepositoryId, String> = wanted
.iter()
.map(|repository| {
let baseline = store.baseline(peer, &local.id, repository)?;
Ok((repository.clone(), baseline_digest(&baseline)))
})
.collect::<Result<_, String>>()?;
let response: Response = session.exchange(&Request::Report {
workspace: local.id.clone(),
repositories: wanted.clone(),
})?;
let (present, theirs) = match response {
Response::Report {
workspace_present,
repositories,
} => (workspace_present.unwrap_or(true), repositories),
Response::Refused { code, detail } => {
return Err(format!("the peer refused a report ({}): {detail}", code.as_str()))
}
other => return Err(format!("the peer answered a report with {other:?}")),
};
if !present {
return Ok(Outcome {
statuses: wanted
.into_iter()
.map(|repository| RepositoryStatus {
repository,
desired,
continuity,
observed: Observed::Blocked,
blocked: Some(Blocked::WorkspaceMissing),
pending: 0,
incoming: 0,
outgoing: 0,
conflicts: 0,
undecided: 0,
branch: None,
head: None,
})
.collect(),
plans: Vec::new(),
});
}
let mine = local.report(&wanted);
let mut statuses = Vec::new();
let mut plans = Vec::new();
for (here, there) in mine.iter().zip(theirs.iter()) {
let (status, planned) = reconcile_one(
service,
store,
session,
peer,
local,
here,
there,
agreed_when_asked.get(&here.facts.repository),
staging_root,
desired,
continuity,
)?;
plans.push((status.repository.clone(), planned));
statuses.push(status);
}
Ok(Outcome { statuses, plans })
}
#[allow(clippy::too_many_arguments)]
fn reconcile_one(
_service: &PeerService,
store: &Arc<MachineStore>,
session: &mut Session,
peer: &PeerId,
local: &LocalWorkspace,
here: &RepositoryReport,
there: &RepositoryReport,
agreed_when_asked: Option<&String>,
staging_root: &Path,
desired: Desired,
continuity: Continuity,
) -> Result<(RepositoryStatus, Vec<crate::machine::PlannedMove>), String> {
let repository = here.facts.repository.clone();
let mut status = RepositoryStatus {
repository: repository.clone(),
desired,
continuity,
observed: Observed::Checking,
blocked: None,
pending: 0,
incoming: 0,
outgoing: 0,
conflicts: 0,
undecided: 0,
branch: here.facts.branch.clone(),
head: here.facts.head.clone(),
};
if let Err(reason) = eligibility(&here.facts, &there.facts) {
status.observed = Observed::Blocked;
status.blocked = Some(reason);
return Ok((status, Vec::new()));
}
let mine = here.facts.epoch();
let theirs = there.facts.epoch();
if let Err(detail) = policy_of(here).agrees_with(&policy_of(there)) {
status.observed = Observed::Blocked;
status.blocked = Some(Blocked::PolicyMismatch { detail });
return Ok((status, Vec::new()));
}
let seeded_already = store.is_seeded(peer, &local.id, &repository)?;
if !seeded_already {
let repo = local
.repository(&repository)
.ok_or_else(|| format!("{repository} vanished while seeding"))?;
let seeded = wsb::peer::seed::from_head(&repo.workdir)?;
store.put_baseline(peer, &local.id, &repository, &seeded)?;
store.mark_seeded(peer, &local.id, &repository, now())?;
tracing::debug!(
%peer,
repository = %repository,
paths = seeded.len(),
"seeded a baseline from the commit both machines share"
);
}
let baseline = store.baseline(peer, &local.id, &repository)?;
if !readings_are_of_one_moment(agreed_when_asked, &baseline, !seeded_already) {
tracing::debug!(
%peer,
repository = %repository,
"what had been agreed moved while the peer was being asked; \
planning again next pass"
);
status.observed = Observed::Checking;
return Ok((status, Vec::new()));
}
let mut convergence = converge::plan(&here.entries, &there.entries, &baseline);
tracing::debug!(
%peer,
repository = %repository,
here = here.entries.len(),
there = there.entries.len(),
baseline = baseline.len(),
actions = convergence.actions.len(),
moves = %convergence
.actions
.iter()
.filter(|action| !matches!(action, Action::Agree { .. }))
.map(|action| format!("{}:{}", action.kind(), action.path()))
.collect::<Vec<_>>()
.join(" "),
"compared"
);
let decisions = store.resolutions(peer, &local.id, &repository)?;
if !decisions.is_empty() {
for action in convergence.actions.iter_mut() {
if matches!(action, Action::Agree { .. }) {
continue;
}
let path = action.path().to_string();
let Some(keep) = decisions.get(path.as_str()) else {
continue;
};
let mine = here.entries.get(&path).cloned();
let theirs = there.entries.get(&path).cloned();
*action = match (keep.as_str(), mine, theirs) {
("here", Some(entry), _) => Action::Send { path, entry },
("here", None, _) => Action::SendDeletion { path },
(_, _, Some(entry)) => Action::Receive { path, entry },
(_, _, None) => Action::ReceiveDeletion { path },
};
}
}
let dismissals = store.dismissals(peer, &local.id, &repository)?;
let still_dismissed = |path: &str| -> bool {
let Some((here_hash, there_hash)) = dismissals.get(path) else {
return false;
};
let mine = here.entries.get(path).map(|entry| entry.hash.clone());
let theirs = there.entries.get(path).map(|entry| entry.hash.clone());
mine.as_deref() == here_hash.as_deref() && theirs.as_deref() == there_hash.as_deref()
};
let planned: Vec<crate::machine::PlannedMove> = convergence
.actions
.iter()
.filter_map(|action| {
let (direction, size) = match action {
Action::Agree { .. } => return None,
Action::Send { entry, .. } => ("send", Some(entry.size)),
Action::Receive { entry, .. } => ("receive", Some(entry.size)),
Action::SendDeletion { .. } => ("send_deletion", None),
Action::ReceiveDeletion { .. } => ("receive_deletion", None),
Action::Conflict { .. } => ("conflict", None),
};
let path = action.path().to_string();
let dismissed = still_dismissed(&path);
let decision = match decisions.get(path.as_str()).map(String::as_str) {
Some("here") => Some("here"),
Some("there") => Some("there"),
_ => None,
};
let here_hash = here.entries.get(&path).map(|entry| entry.hash.clone());
let there_hash = there.entries.get(&path).map(|entry| entry.hash.clone());
Some(crate::machine::PlannedMove {
path,
direction,
size,
decision,
dismissed,
here_hash,
there_hash,
})
})
.collect();
let mut conflicts = 0usize;
for action in convergence.conflicts() {
if let Action::Conflict {
path,
here: mine,
there: theirs,
base,
} = action
{
conflicts += 1;
tracing::debug!(%repository, %path, "conflict");
store.record_conflict(
peer,
&local.id,
&repository,
path,
mine.as_ref(),
theirs.as_ref(),
base.as_ref(),
now(),
)?;
}
}
for stale in store.conflicts(peer, &local.id)? {
if stale.repository != repository.as_str() {
continue;
}
let still = convergence
.conflicts()
.any(|action| action.path() == stale.path);
if !still {
store.clear_conflict(peer, &local.id, &repository, &stale.path)?;
}
}
let is_undecided = |action: &Action| {
action.moves_data()
&& !decisions.contains_key(action.path())
&& !still_dismissed(action.path())
};
let undecided = convergence.actions.iter().filter(|a| is_undecided(a)).count();
let undecided_outgoing = convergence
.actions
.iter()
.filter(|a| is_undecided(a) && a.leaves_here())
.count();
let incoming_paths: BTreeSet<String> = convergence
.actions
.iter()
.filter(|a| a.moves_data() && a.arrives_here())
.map(|a| a.path().to_string())
.collect();
let outgoing_paths: BTreeSet<String> = convergence
.actions
.iter()
.filter(|a| a.moves_data() && a.leaves_here())
.map(|a| a.path().to_string())
.collect();
let differences = convergence.pending();
let now = Continuity::of(
store.service(peer, Service::Files)?,
store.desired(peer, &local.id)?,
store.mode(peer, &local.id)?,
);
if !continuity.carries_decided() || !now.carries_decided() {
status.observed = Observed::Paused;
status.pending = differences;
status.undecided = undecided;
status.conflicts = conflicts;
return Ok((status, planned));
}
convergence.actions.retain(|action| {
converge::crosses(action, continuity, decisions.contains_key(action.path()))
});
let digest = baseline_digest(&baseline);
let mut applied: BTreeSet<String> = BTreeSet::new();
let wanted: Vec<String> = convergence
.actions
.iter()
.filter_map(|action| match action {
Action::Receive { path, .. } => Some(path.clone()),
_ => None,
})
.collect();
let deletions_here: Vec<String> = convergence
.actions
.iter()
.filter_map(|action| match action {
Action::ReceiveDeletion { path } => Some(path.clone()),
_ => None,
})
.collect();
if !wanted.is_empty() || !deletions_here.is_empty() {
let repo = local
.repository(&repository)
.ok_or_else(|| format!("{repository} vanished while reconciling"))?;
if !deletions_here.is_empty() {
match session.exchange::<_, Response>(&Request::Confirm {
workspace: local.id.clone(),
repository: repository.clone(),
baseline_digest: digest.clone(),
})? {
Response::Acknowledged => {}
Response::Refused { code, detail } => {
if code == RefusalCode::BaselineMismatch {
reseed(store, peer, local, &repository)?;
}
return Ok((refused_status(status, code, detail), planned));
}
other => {
return Err(format!(
"the peer answered a baseline confirmation with {other:?}"
))
}
}
}
let writes = if wanted.is_empty() {
Vec::new()
} else {
match session.exchange::<_, Response>(&Request::Fetch {
workspace: local.id.clone(),
repository: repository.clone(),
epoch: theirs.clone(),
paths: wanted.clone(),
})? {
Response::Fetched { writes } => writes,
Response::Refused { code, detail } => {
if code == RefusalCode::BaselineMismatch {
reseed(store, peer, local, &repository)?;
}
return Ok((refused_status(status, code, detail), planned));
}
other => return Err(format!("the peer answered a fetch with {other:?}")),
}
};
match super::transfer::apply(
&repo.workdir,
&repository,
&repo.git_dir,
&mine,
&writes,
&deletions_here,
&here.entries,
staging_root,
) {
Ok(landed) => applied.extend(landed),
Err(super::transfer::ApplyRefusal::EpochMoved) => {
status.observed = Observed::Checking;
return Ok((status, planned));
}
Err(other) => return Err(format!("{repository}: {other:?}")),
}
}
let sending: Vec<Write> = convergence
.actions
.iter()
.filter_map(|action| match action {
Action::Send { path, entry } => Some((path.clone(), entry.clone())),
_ => None,
})
.map(|(path, entry)| -> Result<Write, String> {
let repo = local
.repository(&repository)
.ok_or_else(|| format!("{repository} vanished while reading {path}"))?;
let absolute = scan::local_path(&repo.workdir, &path)?;
let content = match entry.kind {
converge::EntryKind::Symlink => {
let target = std::fs::read_link(&absolute)
.map_err(|e| format!("cannot read the link {path}: {e}"))?;
wsb::peer::identity::base64_encode(scan::to_slash(&target).as_bytes())
}
converge::EntryKind::File => {
let bytes = std::fs::read(&absolute)
.map_err(|e| format!("cannot read {path}: {e}"))?;
wsb::peer::identity::base64_encode(&bytes)
}
};
Ok(Write {
path,
entry,
content,
})
})
.collect::<Result<Vec<_>, _>>()?;
let deletions_there: Vec<String> = convergence
.actions
.iter()
.filter_map(|action| match action {
Action::SendDeletion { path } => Some(path.clone()),
_ => None,
})
.collect();
if !sending.is_empty() || !deletions_there.is_empty() {
let found = sending
.iter()
.map(|write| &write.path)
.chain(deletions_there.iter())
.filter_map(|path| there.entries.get(path).map(|entry| (path.clone(), entry.clone())))
.collect();
match session.exchange::<_, Response>(&Request::Apply {
workspace: local.id.clone(),
repository: repository.clone(),
epoch: theirs.clone(),
writes: sending,
deletions: deletions_there,
found,
baseline_digest: digest.clone(),
})? {
Response::Applied { applied: landed } => applied.extend(landed),
Response::Refused { code, detail } => {
if code == RefusalCode::BaselineMismatch {
reseed(store, peer, local, &repository)?;
}
return Ok((refused_status(status, code, detail), planned));
}
other => return Err(format!("the peer answered an apply with {other:?}")),
}
}
if !applied.is_empty() {
let acknowledged: Vec<String> = applied.iter().cloned().collect();
match session.exchange::<_, Response>(&Request::Acknowledge {
workspace: local.id.clone(),
repository: repository.clone(),
epoch: theirs,
paths: acknowledged,
baseline_digest: digest,
})? {
Response::Acknowledged => {}
Response::Refused { code, detail } => {
if code == RefusalCode::BaselineMismatch {
reseed(store, peer, local, &repository)?;
}
return Ok((refused_status(status, code, detail), planned));
}
other => return Err(format!("the peer answered an acknowledge with {other:?}")),
}
}
let mut settled = applied.clone();
for action in &convergence.actions {
if matches!(action, Action::Agree { .. }) {
settled.insert(action.path().to_string());
}
}
let next = converge::advance(&baseline, &convergence, &settled);
store.put_baseline(peer, &local.id, &repository, &next)?;
for path in applied.iter() {
if decisions.contains_key(path.as_str()) {
store.clear_resolution(peer, &local.id, &repository, path)?;
store.clear_conflict(peer, &local.id, &repository, path)?;
}
}
status.pending = differences.saturating_sub(applied.len());
status.incoming = incoming_paths.difference(&applied).count();
status.outgoing = outgoing_paths.difference(&applied).count();
status.undecided = if continuity.takes_undecided() {
undecided_outgoing
} else {
undecided
};
status.conflicts = conflicts;
status.observed = if conflicts > 0 {
Observed::Conflict
} else if status.undecided > 0 {
Observed::Undecided
} else if status.pending > 0 {
Observed::Syncing
} else if convergence.is_converged() && applied.is_empty() {
Observed::Synced
} else {
Observed::Ready
};
Ok((status, planned))
}
fn policy_of(report: &RepositoryReport) -> Policy {
Policy {
include_ignored: report.policy.include_ignored.clone(),
exclude: report.policy.exclude.clone(),
}
}
fn readings_are_of_one_moment(
agreed_when_asked: Option<&String>,
baseline_now: &wsb::peer::converge::Baseline,
this_pass_seeded: bool,
) -> bool {
if this_pass_seeded {
return true;
}
match agreed_when_asked {
Some(when_asked) => &baseline_digest(baseline_now) == when_asked,
None => false,
}
}
fn reseed(
store: &Arc<MachineStore>,
peer: &PeerId,
local: &LocalWorkspace,
repository: &RepositoryId,
) -> Result<(), String> {
let repo = local
.repository(repository)
.ok_or_else(|| format!("{repository} vanished while re-seeding"))?;
let seeded = wsb::peer::seed::from_head(&repo.workdir)?;
store.put_baseline(peer, &local.id, repository, &seeded)?;
store.mark_seeded(peer, &local.id, repository, now())?;
tracing::warn!(
%peer,
repository = %repository,
paths = seeded.len(),
"the two machines no longer shared a baseline; re-seeded from the commit they share"
);
Ok(())
}
fn refused_status(mut status: RepositoryStatus, code: RefusalCode, detail: String) -> RepositoryStatus {
status.observed = match code {
RefusalCode::EpochMoved | RefusalCode::BaselineMismatch => Observed::Checking,
RefusalCode::NotEnabled => Observed::Idle,
RefusalCode::ServiceOff => Observed::Idle,
RefusalCode::UnknownWorkspace => {
status.blocked = Some(Blocked::WorkspaceMissing);
Observed::Blocked
}
RefusalCode::UnknownRepository => {
status.blocked = Some(Blocked::SubmoduleMissing {
here: true,
there: false,
});
Observed::Blocked
}
RefusalCode::PolicyMismatch => {
status.blocked = Some(Blocked::PolicyMismatch { detail });
Observed::Blocked
}
RefusalCode::ForbiddenPath
| RefusalCode::Malformed
| RefusalCode::Internal
| RefusalCode::NoSuchService
| RefusalCode::PluginAbsent
| RefusalCode::PluginRefused => Observed::Error,
};
status
}
pub const SWEEP: std::time::Duration = std::time::Duration::from_secs(3);
pub fn spawn_reconcile_loop(
machine: std::sync::Arc<crate::machine::MachineState>,
runtime: tokio::runtime::Handle,
) {
std::thread::Builder::new()
.name("wsb-peer-reconcile".to_string())
.spawn(move || loop {
let relationships = machine.store.relationships().unwrap_or_default();
for (peer, workspace, desired) in relationships {
if desired == Desired::Disabled {
continue;
}
let Some(local) = futures::executor::block_on({
let machine = machine.clone();
let workspace = workspace.clone();
async move {
if let Err(error) = machine.refresh_tree(&workspace).await {
tracing::debug!(%workspace, %error, "could not re-read the repository tree");
}
machine.continuity(&workspace).await
}
})
.unwrap_or_else(|error| {
tracing::warn!(%workspace, %error, "not reconciled");
None
}) else {
continue;
};
let outcome = reconcile_once(&machine, &peer, &local.1, &runtime);
if let Err(error) = outcome {
tracing::debug!(%peer, %workspace, %error, "reconciliation did not complete");
}
}
machine.nudge.wait(SWEEP);
})
.expect("the reconcile thread starts");
}
fn reconcile_once(
machine: &std::sync::Arc<crate::machine::MachineState>,
peer: &PeerId,
local: &LocalWorkspace,
_runtime: &tokio::runtime::Handle,
) -> Result<Outcome, String> {
let mut session = machine.peers.connect(peer)?;
let outcome = reconcile(
&machine.peers,
&machine.store,
&mut session,
peer,
local,
&machine.staging_root(),
);
session.shutdown();
let outcome = outcome?;
for status in &outcome.statuses {
tracing::debug!(
%peer,
repository = %status.repository,
observed = status.observed.as_str(),
blocked = status.blocked.as_ref().map(|reason| reason.code()).unwrap_or(""),
pending = status.pending,
conflicts = status.conflicts,
"reconciled"
);
}
machine.record_statuses(peer, &local.id, &outcome.statuses);
for (repository, planned) in &outcome.plans {
machine.record_plan(peer, &local.id, repository, planned.clone());
}
Ok(outcome)
}
#[cfg(test)]
mod one_moment_tests {
use super::*;
use wsb::peer::converge::{Baseline, Entry, EntryKind};
fn entry(bytes: &[u8]) -> Entry {
Entry {
kind: EntryKind::File,
hash: wsb::peer::converge::hash_bytes(bytes),
size: bytes.len() as u64,
executable: false,
}
}
fn before_and_after() -> (Baseline, Baseline) {
let mut before = Baseline::new();
for (path, bytes) in [
(".gitignore", &b"target\n"[..]),
("README.md", &b"# fixture\n"[..]),
("child/child.txt", &b"child\n"[..]),
("committed.txt", &b"to be committed\n"[..]),
] {
before.insert(path.to_string(), entry(bytes));
}
let mut after = before.clone();
after.insert("after-commit.txt".to_string(), entry(b"carried again\n"));
(before, after)
}
#[test]
fn test11591_an_agreement_that_moved_mid_pass_is_not_planned_from() {
let (before, after) = before_and_after();
let asked_with = baseline_digest(&before);
assert!(
!readings_are_of_one_moment(Some(&asked_with), &after, false),
"a pass planned from a report and an agreement taken at different \
moments, which is what deleted the file"
);
}
#[test]
fn test11592_an_agreement_that_held_still_is_planned_from() {
let (before, _) = before_and_after();
let asked_with = baseline_digest(&before);
assert!(readings_are_of_one_moment(
Some(&asked_with),
&before,
false
));
}
#[test]
fn test11593_the_seeding_pass_is_not_refused_for_seeding() {
let (before, after) = before_and_after();
let asked_with = baseline_digest(&before);
assert!(
readings_are_of_one_moment(Some(&asked_with), &after, true),
"the pass that seeds an agreement was refused for having seeded it"
);
}
#[test]
fn test11594_a_pass_with_no_snapshot_does_not_plan() {
let (_, after) = before_and_after();
assert!(!readings_are_of_one_moment(None, &after, false));
}
#[test]
fn test11595_a_gained_path_changes_the_digest() {
let (before, after) = before_and_after();
assert_ne!(
baseline_digest(&before),
baseline_digest(&after),
"an agreement that gained a path has the same digest, so nothing \
above is detecting anything"
);
}
}