1use std::collections::{BTreeMap, BTreeSet, HashMap};
62use std::net::SocketAddr;
63use std::path::{Path, PathBuf};
64use std::sync::Mutex;
65use std::time::Duration;
66
67use borg::collective::{self, Config, Route};
68use iroh::Endpoint;
69use iroh::endpoint::{Builder, ConnectOptions, Connection, Incoming, IncomingAddr, PortmapperConfig, RecvStream, SendStream, presets};
70use iroh::tls::CaTlsConfig;
71use serde::{Deserialize, Serialize};
72
73use crate::addr::{self, Hint, Relay, Target};
74use crate::identity::Identity;
75use crate::invite::{self, Invite, Ticket};
76use crate::lines::{self, Hash, REASONING, SYNCED, logs};
77use crate::membership::Membership;
78use crate::origin::{self, Origin};
79use crate::recon::{self, Recon};
80use crate::{from_hex, peers, to_hex};
81use crate::{live, steer};
82
83mod vectors;
84
85pub const ALPN: &[u8] = b"borg/mesh/13";
89pub const LEGACY_ALPN: &[u8] = b"borg/mesh/12";
92fn older() -> Vec<Vec<u8>> {
96 let below = version(LEGACY_ALPN).unwrap_or(0);
97 (1..below).rev().map(|v| format!("borg/mesh/{v}").into_bytes()).collect()
98}
99
100#[derive(Clone, Copy)]
102struct Peer<'a> {
103 key: &'a str,
104 alpn: &'a [u8],
105}
106
107fn spoken(alpn: &[u8]) -> bool {
109 alpn == ALPN || alpn == LEGACY_ALPN
110}
111
112const PLAYBOOKS: &str = "playbooks.jsonl";
116
117const STEPS: [&str; 6] = [SYNCED[0], SYNCED[1], SYNCED[2], PLAYBOOKS, SYNCED[3], SYNCED[4]];
119
120fn synced(alpn: &[u8]) -> impl Iterator<Item = &'static str> {
123 let legacy = alpn == LEGACY_ALPN;
124 STEPS.into_iter().filter(move |f| !legacy || *f != borg::msg::FILE)
125}
126
127fn version(alpn: &[u8]) -> Option<u32> {
129 std::str::from_utf8(alpn).ok()?.strip_prefix("borg/mesh/")?.parse().ok()
130}
131pub const JOIN_ALPN: &[u8] = b"borg/join/1";
133const MAX_FRAME: usize = 256 << 20;
135const MAX_GIVE: usize = 64 << 20;
138pub const REPLICAS: usize = 2;
140const MAX_ASK: usize = 1000;
142const LEGACY_GOAL_LOGS: usize = 7;
145
146#[derive(Debug, Clone)]
150pub struct Place {
151 pub config: PathBuf,
153 pub base: PathBuf,
155 pub dir: String,
157 pub collective: Option<String>,
160}
161
162impl Place {
163 pub fn from_env(dir: &str) -> Place {
164 Place { config: collective::config_path(), base: borg::learn::home(), dir: dir.into(), collective: None }
165 }
166
167 pub fn named(&self, name: &str) -> Place {
169 Place { collective: Some(name.into()), ..self.clone() }
170 }
171
172 pub fn route(&self) -> Route {
174 self.resolve().1
175 }
176
177 fn resolve(&self) -> (Option<Config>, Route) {
178 match collective::load_from(&self.config) {
179 Ok(cfg) => {
180 let r = match &self.collective {
181 None => collective::route(cfg.as_ref(), &self.dir, &self.base),
182 Some(name) => match cfg.as_ref() {
183 None if name == "default" => collective::route(None, &self.dir, &self.base),
184 None => Route::Off,
185 Some(c) => c.collectives.iter().find(|e| e.name == *name).map_or(Route::Off, |e| Route::Collective {
186 name: e.name.clone(),
187 home: collective::store(&self.base, &e.name),
188 share: e.share,
189 }),
190 },
191 };
192 (cfg, r)
193 }
194 Err(_) => (None, Route::Off),
195 }
196 }
197
198 pub fn relay(&self) -> Result<Relay, String> {
200 let (cfg, route) = self.resolve();
201 let configured = match (&cfg, &route) {
202 (Some(c), Route::Collective { name, .. }) => c.collectives.iter().find(|e| e.name == *name).and_then(|e| e.relay.clone()),
203 _ => None,
204 };
205 Relay::choose(configured.as_deref())
206 }
207
208 pub fn gate(&self) -> Result<(String, PathBuf), String> {
210 let (cfg, route) = self.resolve();
211 if let (Some(name), Route::Off) = (&self.collective, &route) {
212 return Err(format!("collective {name:?} is not in {}", self.config.display()));
213 }
214 let (name, home) = gate(&route)?;
215 if let Some(local) = collective::local_owner_of(cfg.as_ref(), home, &self.base) {
216 return Err(format!("store {} also belongs to local collective {local:?}: refusing to sync", home.display()));
217 }
218 Ok((name.to_string(), home.to_path_buf()))
219 }
220}
221
222pub fn gate(route: &Route) -> Result<(&str, &Path), String> {
224 match route {
225 Route::Collective { name, home, .. } if collective::may_sync(route) => Ok((name, home)),
226 Route::Collective { name, .. } => Err(format!("collective {name:?} is local: it never leaves this machine, refusing to sync")),
227 Route::Off => Err("this repo belongs to no collective (off): nothing may sync".into()),
228 }
229}
230
231#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
233pub struct Report {
234 pub sent: usize,
236 pub received: usize,
237 pub rejected: usize,
239 pub quarantined: usize,
241 pub rounds: usize,
243 pub fps: usize,
244 pub hashes: usize,
245 pub replicated: usize,
247 pub answered: usize,
249 pub presence: usize,
251 pub steered: usize,
253 pub dropped: usize,
254 pub joined: usize,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct Signed {
261 pub line: String,
262 pub origin: Origin,
263}
264
265#[derive(Debug, Serialize, Deserialize)]
266#[serde(tag = "t", rename_all = "snake_case")]
267enum Msg {
268 Hello {
271 collective: String,
272 listen: Option<String>,
273 #[serde(default)]
274 relay: Option<String>,
275 },
276 Refused {
277 reason: String,
278 },
279 Join {
280 invite: Invite,
281 listen: Option<String>,
282 relay: Option<String>,
283 },
284 Welcome {
285 docs: Vec<Membership>,
286 },
287 Membership {
288 docs: Vec<Membership>,
289 },
290 Round {
291 round: recon::Round,
292 },
293 Lines {
294 lines: Vec<Signed>,
295 },
296 Goals {
298 ids: Vec<String>,
299 },
300 Offer {
301 hashes: Vec<String>,
302 },
303 Want {
304 hashes: Vec<String>,
305 },
306 Ask {
307 commits: Vec<String>,
308 },
309 Live {
310 presence: Vec<borg::presence::Remote>,
311 #[serde(default)]
312 directives: Directives,
313 },
314}
315
316#[derive(Debug, Default)]
319struct Directives;
320
321impl Serialize for Directives {
322 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
323 s.collect_seq(std::iter::empty::<()>())
324 }
325}
326
327impl<'de> Deserialize<'de> for Directives {
328 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
329 serde::de::IgnoredAny::deserialize(d).map(|_| Directives)
330 }
331}
332
333fn ca_trust() -> CaTlsConfig {
338 let system = CaTlsConfig::system();
339 match system.server_cert_verifier(iroh::tls::default_provider()) {
340 Ok(_) => system,
341 Err(_) => CaTlsConfig::embedded(),
342 }
343}
344
345pub struct Node {
347 place: Place,
348 home: PathBuf,
350 me: Identity,
351 endpoint: Endpoint,
352 store: Mutex<()>,
354 announce: bool,
356 relay: Relay,
358 legacy: Mutex<HashMap<(String, String, usize), BTreeSet<Hash>>>,
362}
363
364impl Node {
365 pub async fn bind(place: &Place, addr: SocketAddr) -> Result<Node, String> {
368 Node::open(place, Some(addr), true, |b| b).await
369 }
370
371 pub async fn joining(place: &Place, addr: SocketAddr) -> Result<Node, String> {
373 Node::open(place, Some(addr), false, |b| b).await
374 }
375
376 pub async fn open(place: &Place, addr: Option<SocketAddr>, member: bool, tweak: impl FnOnce(Builder) -> Builder) -> Result<Node, String> {
380 let (_, home) = place.gate()?;
381 let relay = place.relay()?;
382 let me = Identity::load_or_create(&home)?;
383 if member {
384 members(&home)?;
385 }
386 let builder = match &relay {
387 Relay::Default => Endpoint::builder(presets::N0),
389 Relay::Custom(_) => Endpoint::builder(presets::Minimal).relay_mode(relay.mode()),
391 Relay::Disabled => Endpoint::builder(presets::Minimal).relay_mode(relay.mode()).portmapper_config(PortmapperConfig::Disabled),
393 };
394 let mut builder = builder
395 .secret_key(me.iroh_secret())
396 .alpns([vec![ALPN.to_vec(), JOIN_ALPN.to_vec(), LEGACY_ALPN.to_vec()], older()].concat())
397 .clear_ip_transports()
398 .ca_tls_config(ca_trust());
399 if let Some(a) = addr {
400 builder = builder.bind_addr(a).map_err(|e| e.to_string())?;
401 }
402 let endpoint = tweak(builder).bind().await.map_err(|e| format!("bind {}: {e}", addr.map(|a| a.to_string()).unwrap_or("relay".into())))?;
403 Ok(Node { place: place.clone(), home, me, endpoint, store: Mutex::new(()), announce: true, relay, legacy: Mutex::default() })
404 }
405
406 pub async fn online(&self, within: Duration) -> bool {
409 self.relay == Relay::Disabled || tokio::time::timeout(within, self.endpoint.online()).await.is_ok()
410 }
411
412 pub fn home_relay(&self) -> Option<String> {
414 self.endpoint.addr().relay_urls().next().map(|u| u.to_string())
415 }
416
417 pub fn hints(&self) -> Vec<Hint> {
420 let mut h: Vec<Hint> = self.tickets().iter().filter_map(|t| Target::parse(t).ok()).flat_map(|t| t.hints).collect();
421 h.extend(self.home_relay().and_then(|u| addr::hints(&u).ok()).into_iter().flatten());
422 h
423 }
424
425 pub fn relay(&self) -> &Relay {
426 &self.relay
427 }
428
429 pub fn quiet(mut self) -> Node {
432 self.announce = false;
433 self
434 }
435
436 pub fn gate(&self) -> Result<(String, PathBuf), String> {
438 let (name, home) = self.place.gate()?;
439 if home != self.home {
440 return Err(format!("{} now routes to store {}, this node serves {}: restart it", self.place.dir, home.display(), self.home.display()));
441 }
442 Ok((name, home))
443 }
444
445 pub fn home(&self) -> &Path {
446 &self.home
447 }
448
449 pub fn public(&self) -> String {
450 self.me.public()
451 }
452
453 fn addr(&self) -> Option<SocketAddr> {
454 self.endpoint.bound_sockets().first().copied()
455 }
456
457 pub fn ticket(&self) -> String {
459 self.tickets().into_iter().next().unwrap_or_else(|| format!("{}@{}", self.me.public(), self.addr().map(|a| a.to_string()).unwrap_or_default()))
460 }
461
462 pub fn tickets(&self) -> Vec<String> {
466 let Some(bound) = self.addr() else { return Vec::new() };
467 let addrs: Vec<SocketAddr> = if bound.ip().is_unspecified() {
468 let mut v: Vec<SocketAddr> = self
469 .endpoint
470 .addr()
471 .ip_addrs()
472 .filter(|a| !a.ip().is_unspecified() && a.is_ipv4() == bound.is_ipv4())
473 .map(|a| SocketAddr::new(a.ip(), bound.port()))
474 .collect();
475 v.sort_by_key(|a| (a.ip().is_loopback(), *a));
476 v.dedup();
477 v
478 } else {
479 vec![bound]
480 };
481 addrs.into_iter().map(|a| format!("{}@{a}", self.me.public())).collect()
482 }
483
484 fn listen(&self) -> Option<String> {
486 self.announce.then(|| self.addr()).flatten().map(|a| a.to_string())
487 }
488
489 pub fn enforce(&self) -> Result<usize, String> {
492 let _w = self.store.lock().unwrap_or_else(|e| e.into_inner());
493 let m = members(&self.home)?;
494 steer::enforce(&self.home, &m, borg::learn::now_ms());
495 origin::apply_if_new(&self.home, &m)
496 }
497
498 pub fn stamp(&self) -> Result<usize, String> {
500 let _w = self.store.lock().unwrap_or_else(|e| e.into_inner());
501 borg::outcome::publish(&self.home);
503 origin::stamp(&self.home, &self.me, &logs(&self.home), borg::learn::now_ms())
504 }
505
506 fn synced(&self, remote: &str, started: i64) {
509 let _w = self.store.lock().unwrap_or_else(|e| e.into_inner());
510 let _ = borg::presence::note_synced(&self.home, remote, started);
511 let _ = borg::presence::forget_mismatch(&self.home, remote);
512 }
513
514 fn mismatch(&self, conn: &Connection, remote: &str) -> String {
518 let m = borg::presence::Mismatch {
519 theirs: String::from_utf8_lossy(conn.alpn()).into_owned(),
520 ours: String::from_utf8_lossy(ALPN).into_owned(),
521 ms: borg::learn::now_ms(),
522 };
523 let (line, told) = (m.line(remote), m.told(&self.me.public()));
524 {
525 let _w = self.store.lock().unwrap_or_else(|e| e.into_inner());
526 let _ = borg::presence::note_mismatch(&self.home, remote, m);
527 }
528 conn.close(2u32.into(), told.as_bytes());
529 line
530 }
531
532 pub(crate) fn unreachable(&self, remote: &str) {
534 let _w = self.store.lock().unwrap_or_else(|e| e.into_inner());
535 let _ = borg::presence::forget_synced(&self.home, remote);
536 }
537
538 fn announced(&self) -> Option<String> {
540 self.announce.then(|| self.home_relay()).flatten()
541 }
542
543 pub async fn sync(&self, target: &str) -> Result<Report, String> {
545 self.fetch(target, &[]).await
546 }
547
548 pub async fn fetch(&self, target: &str, commits: &[String]) -> Result<Report, String> {
551 let (name, home) = self.gate()?;
552 let (name, home) = (name.as_str(), home.as_path());
553 let membership = members(home)?;
554 let t = Target::parse(target)?;
555 if membership.role_of(&t.key).is_none() {
556 return Err(format!("{} is not a member of {name}: refusing to connect", t.key));
557 }
558 let started = borg::learn::now_ms();
559 let alpns = [vec![LEGACY_ALPN.to_vec()], older()].concat();
560 let connecting = self.endpoint.connect_with_opts(t.endpoint_addr(&self.relay)?, ALPN, ConnectOptions::new().with_additional_alpns(alpns));
561 let conn = connecting.await.map_err(|e| format!("connect {target}: {e}"))?.await.map_err(|e| format!("connect {target}: {e}"))?;
562 if !spoken(conn.alpn()) {
563 return Err(self.mismatch(&conn, &t.key));
564 }
565 let result = async {
566 let remote = authorise(&conn, &membership, name)?;
567 let (mut tx, mut rx) = conn.open_bi().await.map_err(|e| e.to_string())?;
568 let hello = Msg::Hello { collective: name.into(), listen: self.listen(), relay: self.announced() };
569 let Msg::Hello { collective, .. } = swap(true, &mut tx, &mut rx, hello).await? else {
570 return Err("protocol: expected hello".into());
571 };
572 if collective != name {
573 return Err(format!("peer answered for collective {collective:?}, not {name:?}"));
574 }
575 let report = self.exchange(Peer { key: &remote, alpn: conn.alpn() }, true, &mut tx, &mut rx, membership, commits).await?;
576 let _ = tx.finish();
577 self.synced(&remote, started);
578 Ok(report)
579 }
580 .await;
581 conn.close(0u32.into(), if result.is_ok() { b"done" } else { b"error" });
582 vectors::reindexed(&self.home, result).await
583 }
584
585 pub async fn join(&self, ticket: &Ticket) -> Result<(Membership, Report), String> {
589 let (name, home) = self.gate()?;
590 let (name, home) = (name.as_str(), home.as_path());
591 if ticket.invite.collective != name {
592 return Err(format!("the ticket is for collective {:?}, this node is {name:?}", ticket.invite.collective));
593 }
594 let admin = ticket.target();
595 let conn = self.endpoint.connect(admin.endpoint_addr(&self.relay)?, JOIN_ALPN).await.map_err(|e| format!("connect {}: {e}", admin.key))?;
596 let me = self.public();
597 let result = async {
598 let (mut tx, mut rx) = conn.open_bi().await.map_err(|e| e.to_string())?;
599 let ask = Msg::Join { invite: ticket.invite.clone(), listen: self.listen(), relay: self.announced() };
600 let Msg::Welcome { docs } = swap(true, &mut tx, &mut rx, ask).await? else { return Err("protocol: expected welcome".into()) };
601 let _ = tx.finish();
602 let _w = self.store.lock().unwrap_or_else(|e| e.into_inner());
603 let installed = match origin::join(home, name, &docs) {
604 Ok((m, _)) => m,
605 Err(e) => Membership::load(home)?.filter(|m| m.role_of(&me).is_some()).ok_or(e)?,
607 };
608 if installed.role_of(&me).is_none() {
609 return Err(format!("the admin's membership of {name} does not list this node ({me})"));
610 }
611 steer::enforce(home, &installed, borg::learn::now_ms());
612 Ok(installed)
613 }
614 .await;
615 conn.close(0u32.into(), if result.is_ok() { b"done" } else { b"error" });
616 let installed = result?;
617 peers::remember(home, &admin.key, &addr::join_hints(&admin.hints))?;
618 let report = self.sync(&admin.to_string()).await.map_err(|e| format!("joined {name}, but the first sync failed: {e}"))?;
619 Ok((installed, report))
620 }
621
622 async fn admit(&self, conn: &Connection, observed: Option<SocketAddr>) -> Result<Report, String> {
626 let (name, home) = self.gate()?;
627 let (name, home) = (name.as_str(), home.as_path());
628 let joiner = to_hex(conn.remote_id().as_bytes());
629 let (mut tx, mut rx) = conn.accept_bi().await.map_err(|e| e.to_string())?;
630 let Msg::Join { invite, listen, relay } = recv(&mut rx).await? else { return Err("protocol: expected join".into()) };
631 let verdict = {
632 let _w = self.store.lock().unwrap_or_else(|e| e.into_inner());
633 invite::redeem(home, &self.me, name, &invite, &joiner, borg::learn::now_ms())
634 };
635 let m = match verdict {
636 Ok(m) => m,
637 Err(reason) => {
638 send(&mut tx, &Msg::Refused { reason: reason.clone() }).await?;
639 let _ = tx.finish();
640 let _ = tokio::time::timeout(Duration::from_secs(5), conn.closed()).await;
642 return Err(format!("join by {joiner}: {reason}"));
643 }
644 };
645 steer::enforce(home, &m, borg::learn::now_ms());
646 heard(home, &joiner, listen, relay, observed);
647 send(&mut tx, &Msg::Welcome { docs: Membership::history(home) }).await?;
648 let _ = tx.finish();
649 conn.closed().await; Ok(Report { joined: 1, ..Report::default() })
651 }
652
653 pub async fn incoming(&self) -> Option<Incoming> {
655 self.endpoint.accept().await
656 }
657
658 pub async fn accept(&self) -> Result<Report, String> {
660 self.handle(self.incoming().await.ok_or("endpoint closed")?).await
661 }
662
663 pub async fn handle(&self, incoming: Incoming) -> Result<Report, String> {
665 let observed = match incoming.remote_addr() {
667 IncomingAddr::Ip(a) => Some(a),
668 _ => None,
669 };
670 let started = borg::learn::now_ms();
671 let conn = incoming.await.map_err(|e| format!("handshake: {e}"))?;
672 if !spoken(conn.alpn()) && conn.alpn() != JOIN_ALPN {
673 let (name, home) = self.gate()?;
674 let remote = authorise(&conn, &members(&home)?, &name)?;
675 return Err(self.mismatch(&conn, &remote));
676 }
677 let session = async {
678 let (name, home) = self.gate()?;
679 let (name, home) = (name.as_str(), home.as_path());
680 let membership = members(home)?;
681 let remote = authorise(&conn, &membership, name)?;
682 let (mut tx, mut rx) = conn.accept_bi().await.map_err(|e| e.to_string())?;
683 let Msg::Hello { collective, listen, relay } = recv(&mut rx).await? else { return Err("protocol: expected hello".into()) };
684 if collective != name {
685 let reason = format!("this node serves collective {name:?}, not {collective:?}");
686 send(&mut tx, &Msg::Refused { reason: reason.clone() }).await?;
687 return Err(reason);
688 }
689 heard(home, &remote, listen, relay, observed);
690 send(&mut tx, &Msg::Hello { collective: name.into(), listen: self.listen(), relay: self.announced() }).await?;
691 let report = self.exchange(Peer { key: &remote, alpn: conn.alpn() }, false, &mut tx, &mut rx, membership, &[]).await?;
692 let _ = tx.finish();
693 conn.closed().await; self.synced(&remote, started);
695 Ok(report)
696 };
697 let result = if conn.alpn() == JOIN_ALPN { self.admit(&conn, observed).await } else { session.await };
698 if let Err(e) = &result {
699 conn.close(1u32.into(), e.as_bytes());
700 }
701 vectors::reindexed(&self.home, result).await
702 }
703
704 pub async fn close(&self) {
705 self.endpoint.close().await;
706 }
707
708 async fn exchange(
710 &self,
711 peer: Peer<'_>,
712 dialler: bool,
713 tx: &mut SendStream,
714 rx: &mut RecvStream,
715 mine: Membership,
716 ask: &[String],
717 ) -> Result<Report, String> {
718 let Peer { key: remote, alpn } = peer;
719 let home = self.home.as_path();
720 let mut report = Report::default();
721 let Msg::Membership { docs } = swap(dialler, tx, rx, Msg::Membership { docs: Membership::history(home) }).await? else {
722 return Err("protocol: expected membership".into());
723 };
724 let mine = {
725 let _w = self.store.lock().unwrap_or_else(|e| e.into_inner());
726 let (held, _) = Membership::adopt(home, &docs).unwrap_or((mine, false));
727 report.quarantined += origin::apply_if_new(home, &held)?;
728 steer::enforce(home, &held, borg::learn::now_ms()); held
730 };
731 if mine.role_of(remote).is_none() {
732 return Err(format!("{remote} is no longer a member of {}", mine.collective));
733 }
734 self.stamp()?;
735 let origins = origin::load(home);
736
737 for file in synced(alpn) {
739 if file == PLAYBOOKS {
740 self.skip(dialler, tx, rx, (remote.to_string(), file.to_string(), 0)).await?;
741 } else {
742 self.reconcile(dialler, tx, rx, &home.join(file), &origins, &mine, &mut report).await?;
743 }
744 }
745
746 let Msg::Goals { ids } = swap(dialler, tx, rx, Msg::Goals { ids: Vec::new() }).await? else {
748 return Err("protocol: expected goals".into());
749 };
750 for goal in legacy_goals(ids) {
751 for log in 0..LEGACY_GOAL_LOGS {
752 self.skip(dialler, tx, rx, (remote.to_string(), goal.clone(), log)).await?;
753 }
754 }
755
756 let path = home.join(REASONING);
758 let held = lines::read(&path);
759 let set = origin::admitted(held.clone(), &origins, &mine);
760 self.gate()?;
761 let me = self.me.public();
762 let offer: BTreeSet<Hash> =
763 set.keys().filter(|h| origins.get(*h).is_some_and(|o| o.key == me) && replicas(h, &mine, &me).iter().any(|k| k == remote)).copied().collect();
764 let Msg::Offer { hashes } = swap(dialler, tx, rx, Msg::Offer { hashes: offer.iter().map(|h| to_hex(h)).collect() }).await? else {
765 return Err("protocol: expected offer".into());
766 };
767 let offered: BTreeSet<Hash> = hashes.iter().map(|h| from_hex::<32>(h)).collect::<Result<_, _>>()?;
768 let want: BTreeSet<Hash> = offered.into_iter().filter(|h| !held.contains_key(h)).collect();
769 let Msg::Want { hashes } = swap(dialler, tx, rx, Msg::Want { hashes: want.iter().map(|h| to_hex(h)).collect() }).await? else {
770 return Err("protocol: expected want".into());
771 };
772 let wanted: Vec<Hash> = hashes.iter().map(|h| from_hex::<32>(h)).collect::<Result<Vec<_>, _>>()?.into_iter().filter(|h| offer.contains(h)).collect();
773 let give = signed(&set, &origins, wanted.iter());
774 report.replicated += give.len();
775 self.gate()?; let Msg::Lines { lines: got } = swap(dialler, tx, rx, Msg::Lines { lines: give }).await? else {
777 return Err("protocol: expected lines".into());
778 };
779 self.store_lines(&path, got, &want, &mine, Some(remote), &mut report)?;
780
781 let ask: Vec<String> = ask.iter().take(MAX_ASK).cloned().collect();
783 let Msg::Ask { commits: theirs } = swap(dialler, tx, rx, Msg::Ask { commits: ask.clone() }).await? else {
784 return Err("protocol: expected ask".into());
785 };
786 let held = lines::read(&path);
787 let set = origin::admitted(held.clone(), &origin::load(home), &mine);
788 let answer: Vec<&Hash> = set.iter().filter(|(_, l)| about(&theirs, l)).map(|(h, _)| h).take(MAX_ASK).collect();
789 let give = signed(&set, &origin::load(home), answer.into_iter());
790 report.answered += give.len();
791 self.gate()?; let Msg::Lines { lines: got } = swap(dialler, tx, rx, Msg::Lines { lines: give }).await? else {
793 return Err("protocol: expected lines".into());
794 };
795 let want: BTreeSet<Hash> = got.iter().filter(|s| about(&ask, &s.line)).map(|s| lines::hash(&s.line)).filter(|h| !held.contains_key(h)).collect();
796 self.store_lines(&path, got, &want, &mine, None, &mut report)?;
797
798 let now = borg::learn::now_ms();
800 let live = Msg::Live { presence: live::outgoing(home, &self.me, now), directives: Directives };
801 self.gate()?; let Msg::Live { presence, .. } = swap(dialler, tx, rx, live).await? else {
803 return Err("protocol: expected live".into());
804 };
805 let _w = self.store.lock().unwrap_or_else(|e| e.into_inner());
806 report.presence += live::receive(home, &mine, &me, presence, now)?;
807 Ok(report)
808 }
809
810 #[allow(clippy::too_many_arguments)]
813 async fn reconcile(
814 &self,
815 dialler: bool,
816 tx: &mut SendStream,
817 rx: &mut RecvStream,
818 path: &Path,
819 origins: &HashMap<Hash, Origin>,
820 mine: &Membership,
821 report: &mut Report,
822 ) -> Result<(), String> {
823 let set = offered(path, origins, mine, borg::learn::now_ms());
824 self.gate()?; let mut r = Recon::new(&set);
826 loop {
827 let m = r.message();
828 let Msg::Round { round } = swap(dialler, tx, rx, Msg::Round { round: m.clone() }).await? else {
829 return Err("protocol: expected a reconciliation round".into());
830 };
831 if r.absorb(&m, round)? {
832 break;
833 }
834 }
835 (report.rounds, report.fps, report.hashes) = (report.rounds + r.cost.rounds, report.fps + r.cost.fps, report.hashes + r.cost.hashes);
836 let give = within_budget(signed(&set, origins, r.give.iter()));
837 report.sent += give.len();
838 self.gate()?; let Msg::Lines { lines: got } = swap(dialler, tx, rx, Msg::Lines { lines: give }).await? else {
840 return Err("protocol: expected lines".into());
841 };
842 self.store_lines(path, got, &r.want, mine, None, report)
843 }
844
845 async fn skip(&self, dialler: bool, tx: &mut SendStream, rx: &mut RecvStream, log: (String, String, usize)) -> Result<(), String> {
852 let mut want = BTreeSet::new();
853 if dialler {
854 let given = self.legacy.lock().unwrap_or_else(|e| e.into_inner()).get(&log).cloned().unwrap_or_default();
855 let given: BTreeMap<Hash, ()> = given.into_iter().map(|h| (h, ())).collect();
856 let mut r = Recon::new(&given);
857 loop {
858 let m = r.message();
859 let Msg::Round { round } = swap(true, tx, rx, Msg::Round { round: m.clone() }).await? else {
860 return Err("protocol: expected a reconciliation round".into());
861 };
862 if r.absorb(&m, round)? {
863 break;
864 }
865 }
866 want = r.want;
867 } else {
868 let Msg::Round { round } = recv(rx).await? else {
869 return Err("protocol: expected a reconciliation round".into());
870 };
871 send(tx, &Msg::Round { round }).await?;
872 }
873 let Msg::Lines { lines: got } = swap(dialler, tx, rx, Msg::Lines { lines: Vec::new() }).await? else {
874 return Err("protocol: expected lines".into());
875 };
876 let got: Vec<Hash> = got.iter().map(|s| lines::hash(&s.line)).filter(|h| want.contains(h)).collect();
877 if !got.is_empty() {
878 self.legacy.lock().unwrap_or_else(|e| e.into_inner()).entry(log).or_default().extend(got);
879 }
880 Ok(())
881 }
882
883 fn store_lines(
887 &self,
888 path: &Path,
889 got: Vec<Signed>,
890 want: &BTreeSet<Hash>,
891 m: &Membership,
892 author: Option<&str>,
893 report: &mut Report,
894 ) -> Result<(), String> {
895 let (file, now) = (log_name(path), borg::learn::now_ms());
896 let mut keep: BTreeMap<Hash, Signed> = BTreeMap::new();
897 for s in got {
898 let h = lines::hash(&s.line);
899 let ok = want.contains(&h)
900 && s.origin.verify(&s.line).is_ok()
901 && m.admits(&s.origin.key, s.origin.ms)
902 && author.is_none_or(|a| a == s.origin.key)
903 && lines::travels(file, &s.line, &s.origin.key, now);
904 if ok {
905 keep.insert(h, s);
906 } else {
907 report.rejected += 1;
908 }
909 }
910 let _w = self.store.lock().unwrap_or_else(|e| e.into_inner());
911 let known = origin::load(&self.home);
912 if file != borg::msg::FILE {
913 origin::append(&self.home, &keep.iter().filter(|(h, _)| !known.contains_key(*h)).map(|(_, s)| s.origin.clone()).collect::<Vec<_>>())?;
914 report.received += lines::merge(path, &keep.into_values().map(|s| s.line).collect::<Vec<_>>())?;
915 return Ok(());
916 }
917 let a = borg::live::locked(path, || {
919 let held = travelling(path, &known, m, now);
920 let mut all = held.clone();
921 all.extend(keep.iter().map(|(h, s)| (*h, s.line.clone())));
922 let w = window(&all, per_node(m));
923 let before = keep.len();
924 keep.retain(|h, _| w.contains(h));
925 report.rejected += before - keep.len();
926 let new: Vec<borg::msg::Message> =
927 keep.iter().filter(|(h, _)| !known.contains_key(*h)).filter_map(|(_, s)| serde_json::from_str(&s.line).ok()).collect();
928 origin::append(&self.home, &keep.iter().filter(|(h, _)| !known.contains_key(*h)).map(|(_, s)| s.origin.clone()).collect::<Vec<_>>())?;
929 report.received += lines::merge(path, &keep.into_values().map(|s| s.line).collect::<Vec<_>>())?;
930 prune(path, &held, &w, now)?;
931 steer::admit(&self.home, m, &self.me.public(), &new, now).map_err(|e| format!("admissions: {e}"))
933 })?;
934 (report.steered, report.dropped) = (report.steered + a.new, report.dropped + a.dropped);
935 drop(_w);
936 if a.new > 0 {
938 borg::veto::apply_messages(&self.home, now);
939 }
940 Ok(())
941 }
942}
943
944fn offered(path: &Path, origins: &HashMap<Hash, Origin>, m: &Membership, now: i64) -> BTreeMap<Hash, String> {
947 let mut set = travelling(path, origins, m, now);
948 if log_name(path) == borg::msg::FILE {
949 let w = window(&set, per_node(m));
950 set.retain(|h, _| w.contains(h));
951 }
952 set
953}
954
955fn travelling(path: &Path, origins: &HashMap<Hash, Origin>, m: &Membership, now: i64) -> BTreeMap<Hash, String> {
958 let file = log_name(path);
959 let mut set = origin::admitted(lines::read(path), origins, m);
960 set.retain(|h, l| origins.get(h).is_some_and(|o| lines::travels(file, l, &o.key, now)));
961 set
962}
963
964fn per_node(m: &Membership) -> usize {
967 (borg::msg::KEEP / m.members.len().max(1)).max(1)
968}
969
970fn window(set: &BTreeMap<Hash, String>, per_node: usize) -> BTreeSet<Hash> {
977 let mut all: Vec<(i64, Hash, borg::msg::Message)> = set
978 .iter()
979 .filter_map(|(h, l)| serde_json::from_str::<borg::msg::Message>(l).ok().filter(borg::msg::Message::well_formed).map(|m| (m.ms, *h, m)))
980 .collect();
981 all.sort_by_key(|x| (x.0, x.1));
982 let mut last: HashMap<(String, String, String), i64> = HashMap::new();
983 let mut paced: Vec<(Hash, String, bool)> = Vec::new();
984 for (ms, h, m) in all {
985 if m.kind.privileged() {
986 paced.push((h, m.node, true));
987 continue;
988 }
989 let slot = last.entry((m.node.clone(), m.by, m.to)).or_insert(i64::MIN);
990 if ms.saturating_sub(*slot) >= borg::msg::RATE_MS {
991 *slot = ms;
992 paced.push((h, m.node, false));
993 }
994 }
995 let mut per: HashMap<(String, bool), usize> = HashMap::new();
996 let mut total = [0usize; 2];
997 paced
998 .into_iter()
999 .rev()
1000 .filter(|(_, node, steers)| {
1001 let n = per.entry((node.clone(), *steers)).or_default();
1002 *n += 1;
1003 *n <= per_node
1004 })
1005 .filter(|(_, _, steers)| {
1006 let t = &mut total[usize::from(*steers)];
1007 *t += 1;
1008 *t <= borg::msg::KEEP
1009 })
1010 .map(|(h, _, _)| h)
1011 .collect()
1012}
1013
1014fn prune(path: &Path, held: &BTreeMap<Hash, String>, w: &BTreeSet<Hash>, now: i64) -> Result<(), String> {
1018 let text = std::fs::read_to_string(path).unwrap_or_default();
1019 let expired = |l: &str| serde_json::from_str::<borg::msg::Message>(l).is_ok_and(|m| now - m.ms > borg::msg::TTL_MS + borg::steer::SLACK_MS);
1020 let goes = |l: &str| {
1021 let h = lines::hash(l);
1022 (held.contains_key(&h) && !w.contains(&h)) || expired(l)
1023 };
1024 if !text.lines().any(goes) {
1025 return Ok(());
1026 }
1027 let body: String = text.lines().filter(|l| !goes(l)).map(|l| format!("{l}\n")).collect();
1028 borg::steer::rewrite(path, &body).map_err(|e| format!("{}: {e}", path.display()))
1029}
1030
1031fn log_name(path: &Path) -> &str {
1033 path.file_name().and_then(|f| f.to_str()).unwrap_or_default()
1034}
1035
1036fn legacy_goals(ids: Vec<String>) -> BTreeSet<String> {
1039 let valid = |id: &String| (1..=40).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
1040 ids.into_iter().filter(valid).collect()
1041}
1042
1043fn reachable(listen: &str, observed: Option<SocketAddr>) -> Option<SocketAddr> {
1046 let listen: SocketAddr = listen.parse().ok()?;
1047 let ip = match observed {
1048 Some(o) => o.ip().to_canonical(),
1049 None => listen.ip(),
1050 };
1051 let at = SocketAddr::new(ip, listen.port());
1052 (!at.ip().is_unspecified() && at.port() != 0).then_some(at)
1053}
1054
1055fn heard(home: &Path, key: &str, listen: Option<String>, relay: Option<String>, observed: Option<SocketAddr>) {
1058 let direct = listen.and_then(|l| reachable(&l, observed)).map(|a| a.to_string());
1059 let hints: Vec<String> = direct.into_iter().chain(relay.filter(|r| addr::hints(r).is_ok())).collect();
1060 let _ = peers::remember(home, key, &hints.join(","));
1061}
1062
1063fn about(commits: &[String], line: &str) -> bool {
1066 let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else { return false };
1067 ["commit", "fix"].iter().filter_map(|k| v.get(k)?.as_str()).any(|c| asked(commits, c))
1068}
1069
1070fn asked(asked: &[String], commit: &str) -> bool {
1072 asked.iter().any(|a| a == commit || (a.len() >= 7 && commit.starts_with(a.as_str())))
1073}
1074
1075fn within_budget(lines: Vec<Signed>) -> Vec<Signed> {
1077 let mut used = 0;
1078 lines
1079 .into_iter()
1080 .enumerate()
1081 .take_while(|(i, s)| {
1082 used += s.line.len();
1083 *i == 0 || used <= MAX_GIVE
1084 })
1085 .map(|(_, s)| s)
1086 .collect()
1087}
1088
1089fn signed<'a>(set: &BTreeMap<Hash, String>, origins: &HashMap<Hash, Origin>, hashes: impl Iterator<Item = &'a Hash>) -> Vec<Signed> {
1090 hashes.filter_map(|h| Some(Signed { line: set.get(h)?.clone(), origin: origins.get(h)?.clone() })).collect()
1091}
1092
1093pub fn replicas(h: &Hash, m: &Membership, author: &str) -> Vec<String> {
1097 let mut keys: Vec<(Hash, &str)> = m
1098 .members
1099 .iter()
1100 .filter(|x| x.key != author)
1101 .map(|x| {
1102 let mut s = blake3::Hasher::new();
1103 s.update(h);
1104 s.update(x.key.as_bytes());
1105 (*s.finalize().as_bytes(), x.key.as_str())
1106 })
1107 .collect();
1108 keys.sort_by(|a, b| b.cmp(a));
1109 keys.into_iter().take(REPLICAS).map(|(_, k)| k.to_string()).collect()
1110}
1111
1112fn members(home: &Path) -> Result<Membership, String> {
1113 Membership::load(home)?
1114 .ok_or_else(|| format!("no membership in {}: `borg mesh init` (admin) or `borg mesh join <file>`", crate::membership::path(home).display()))
1115}
1116
1117fn authorise(conn: &Connection, membership: &Membership, name: &str) -> Result<String, String> {
1119 let remote = to_hex(conn.remote_id().as_bytes());
1120 match membership.role_of(&remote) {
1121 Some(_) => Ok(remote),
1122 None => Err(format!("rejected {remote}: not a member of {name}")),
1123 }
1124}
1125
1126async fn swap(dialler: bool, tx: &mut SendStream, rx: &mut RecvStream, m: Msg) -> Result<Msg, String> {
1128 if dialler {
1129 send(tx, &m).await?;
1130 recv(rx).await
1131 } else {
1132 let theirs = recv(rx).await?;
1133 send(tx, &m).await?;
1134 Ok(theirs)
1135 }
1136}
1137
1138async fn send(tx: &mut SendStream, m: &Msg) -> Result<(), String> {
1139 let body = serde_json::to_vec(m).map_err(|e| e.to_string())?;
1140 tx.write_all(&(body.len() as u32).to_be_bytes()).await.map_err(|e| format!("send: {e}"))?;
1141 tx.write_all(&body).await.map_err(|e| format!("send: {e}"))
1142}
1143
1144async fn recv(rx: &mut RecvStream) -> Result<Msg, String> {
1145 let mut len = [0u8; 4];
1146 rx.read_exact(&mut len).await.map_err(|e| format!("receive: {e}"))?;
1147 let len = u32::from_be_bytes(len) as usize;
1148 if len > MAX_FRAME {
1149 return Err(format!("frame of {len} bytes exceeds {MAX_FRAME}"));
1150 }
1151 let mut body = vec![0u8; len];
1152 rx.read_exact(&mut body).await.map_err(|e| format!("receive: {e}"))?;
1153 match serde_json::from_slice(&body).map_err(|e| format!("protocol: {e}"))? {
1154 Msg::Refused { reason } => Err(format!("refused by peer: {reason}")),
1155 m => Ok(m),
1156 }
1157}
1158
1159#[cfg(test)]
1160mod tests {
1161 use super::*;
1162 use serde_json::json;
1163
1164 #[test]
1166 fn lines_about_the_asked_commits() {
1167 let ask = vec!["d0d0d0d".to_string(), "f1f1f1f1f1".to_string()];
1168 let excerpt = json!({"kind": "reasoning", "commit": "d0d0d0d0d0"}).to_string();
1169 let link = |c: &str, f: &str| json!({"kind": "regress", "commit": c, "fix": f}).to_string();
1170 assert!(about(&ask, &excerpt) && about(&ask, &link("aaaaaaaaaa", "f1f1f1f1f1")) && about(&ask, &link("d0d0d0d0d0", "bbbbbbbbbb")));
1171 assert!(!about(&ask, &link("aaaaaaaaaa", "bbbbbbbbbb")) && !about(&ask, "not json"));
1172 }
1173
1174 #[test]
1176 fn a_frame_gives_lines_up_to_its_budget() {
1177 let me = Identity::load_or_create(&std::env::temp_dir().join(format!("borg-mesh-budget-{}", std::process::id()))).unwrap();
1178 let line = |n: usize| {
1179 let l = "x".repeat(n);
1180 Signed { origin: Origin::new(&me, &lines::hash(&l), 1), line: l }
1181 };
1182 let give = within_budget(vec![line(MAX_GIVE / 2), line(MAX_GIVE / 2), line(1)]);
1183 assert_eq!(give.len(), 2);
1184 assert_eq!(within_budget(vec![line(MAX_GIVE + 1), line(1)]).len(), 1);
1185 assert!(within_budget(Vec::new()).is_empty());
1186 }
1187
1188 fn node(tag: &str, share: &str) -> Place {
1191 let base = std::env::temp_dir().join(format!("borg-mesh-sync-{tag}-{}", std::process::id()));
1192 let _ = std::fs::remove_dir_all(&base);
1193 std::fs::create_dir_all(home_of(&base)).unwrap();
1194 let p = Place { config: base.join("collectives.json"), base: base.clone(), dir: base.join("repo").display().to_string(), collective: None };
1195 configure(&p, share);
1196 p
1197 }
1198
1199 fn configure(p: &Place, share: &str) {
1200 let cfg = json!({"default": "off", "collectives": [{"name": "work", "paths": [p.dir], "share": share, "relay": "disabled"}]});
1201 std::fs::write(&p.config, cfg.to_string()).unwrap();
1202 }
1203
1204 fn home_of(base: &Path) -> PathBuf {
1205 base.join("collectives").join("work")
1206 }
1207
1208 fn home(p: &Place) -> PathBuf {
1209 home_of(&p.base)
1210 }
1211
1212 fn lesson(id: &str, version: u32, q: &str) -> String {
1213 json!({"id": id, "origin_ms": 1, "question": q, "version": version}).to_string()
1214 }
1215
1216 fn write(home: &Path, file: &str, lines: &[String]) {
1217 std::fs::write(home.join(file), lines.iter().map(|l| format!("{l}\n")).collect::<String>()).unwrap();
1218 }
1219
1220 const LOCAL: &str = "127.0.0.1:0";
1221
1222 fn team(tag: &str) -> (Place, Place) {
1224 let (a, b) = (node(&format!("{tag}-alice"), "team"), node(&format!("{tag}-bob"), "team"));
1225 let alice = Identity::load_or_create(&home(&a)).unwrap();
1226 let bob = Identity::load_or_create(&home(&b)).unwrap();
1227 let mut m = Membership::found("work", &alice);
1228 m.invite(&alice, &bob.public(), "member").unwrap();
1229 m.save(&home(&a)).unwrap();
1230 m.save(&home(&b)).unwrap();
1231 (a, b)
1232 }
1233
1234 fn rollup(node: &str, version: u64, turns: u64) -> String {
1235 json!({"id": format!("stat:{node}:dev@x:2026-10-12"), "version": version, "day": "2026-10-12", "node": node,
1236 "engineer": "dev@x", "counts": {"turn_end": turns}, "usd": 0.0, "dur_ms": 0})
1237 .to_string()
1238 }
1239
1240 #[tokio::test(flavor = "multi_thread")]
1241 async fn usage_rollups_sync_and_the_latest_version_wins() {
1242 let (pa, pb) = team("stats");
1243 let (a, b) = (home(&pa), home(&pb));
1244 write(&a, "stats.jsonl", &[rollup("na", 1, 3), rollup("na", 2, 5)]);
1245 write(&b, "stats.jsonl", &[rollup("nb", 1, 7)]);
1246 let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
1247 let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
1248 let ticket = alice.ticket();
1249 let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
1250 let (served, synced) = (served.unwrap(), synced.unwrap());
1251 assert_eq!((synced.received, served.received), (2, 1), "both of alice's lines reach bob, bob's one reaches alice");
1252 for h in [&a, &b] {
1253 let r = borg::stats::rollups(h);
1254 assert_eq!(r.len(), 2, "one bucket per node");
1255 assert_eq!(r["stat:na:dev@x:2026-10-12"].counts["turn_end"], 5, "the newest version of alice's bucket");
1256 assert_eq!(r["stat:nb:dev@x:2026-10-12"].counts["turn_end"], 7);
1257 }
1258 }
1259
1260 #[tokio::test(flavor = "multi_thread")]
1263 async fn a_leftover_playbooks_log_is_not_synced() {
1264 let (pa, pb) = team("playbooks");
1265 let (a, b) = (home(&pa), home(&pb));
1266 let edit = json!({"pb": "p1", "edit": "e1", "step": "s1", "text": "run the parser tests", "ms": 1}).to_string();
1267 write(&a, PLAYBOOKS, std::slice::from_ref(&edit));
1268 let h = lines::hash(&edit);
1269 origin::append(&a, &[Origin::new(&Identity::load_or_create(&a).unwrap(), &h, 1)]).unwrap();
1270 let c = json!({"id": "cabc", "text": "locks are taken in id order", "lesson": "L1", "by": "lead@x", "ms": 5, "source": "veto"}).to_string();
1271 write(&a, borg::veto::FILE, std::slice::from_ref(&c));
1272 let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
1273 let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
1274 let ticket = alice.ticket();
1275 let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
1276 let (served, synced) = (served.unwrap(), synced.unwrap());
1277 assert_eq!((synced.received, served.sent), (1, 1), "only the constraint, the step after the playbooks', moves");
1278 assert!(holds(&b, borg::veto::FILE, &c));
1279 assert!(!b.join(PLAYBOOKS).exists(), "no playbook reaches bob");
1280 assert!(holds(&a, PLAYBOOKS, &edit), "alice's leftover stays untouched");
1281 alice.close().await;
1282 bob.close().await;
1283 }
1284
1285 #[tokio::test(flavor = "multi_thread")]
1286 async fn a_ratified_constraint_reaches_the_team_signed() {
1287 let (pa, pb) = team("constraints");
1288 let (a, b) = (home(&pa), home(&pb));
1289 write(&a, "lessons.jsonl", &[lesson("L1", 1, "lock order?")]);
1290 let c = json!({"id": "cabc", "text": "locks are taken in id order", "lesson": "L1", "by": "lead@x", "ms": 5, "source": "veto"}).to_string();
1291 write(&a, borg::veto::FILE, std::slice::from_ref(&c));
1292 let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
1293 let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
1294 let ticket = alice.ticket();
1295 let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
1296 served.unwrap();
1297 assert!(synced.unwrap().received >= 2, "the lesson and the constraint");
1298 assert_eq!(lines::read(&b.join(borg::veto::FILE)).into_values().collect::<Vec<_>>(), std::slice::from_ref(&c));
1299 assert!(origin::load(&b).contains_key(&lines::hash(&c)), "it arrived with its signed origin");
1300 let standing: Vec<String> = borg::conscience::load(&b.join("lessons.jsonl"), None).into_iter().filter(|c| c.standing()).map(|c| c.question).collect();
1301 assert_eq!(standing, ["locks are taken in id order"], "bob's sessions get it as a standing lesson");
1302 alice.close().await;
1303 bob.close().await;
1304 }
1305
1306 const ALL_TO: &str = borg::msg::ALL;
1307
1308 fn message(home: &Path, node: &str, to: &str, ms: i64, text: &str) -> String {
1310 let from = borg::msg::Sender { by: "s1".into(), name: "One of One".into(), harness: "claude".into(), node: node.into() };
1311 let line = serde_json::to_string(&borg::msg::Message::new(borg::msg::TELL, &from, to, None, ms, text)).unwrap();
1312 append_lines(home, borg::msg::FILE, std::slice::from_ref(&line));
1313 line
1314 }
1315
1316 #[tokio::test(flavor = "multi_thread")]
1320 async fn a_tell_reaches_the_team_and_a_forged_node_is_not_shown_as_its() {
1321 let p = crew("msg", &["alice", "bob", "carol"]);
1322 let (a, b, c) = (home(&p[0]), home(&p[1]), home(&p[2]));
1323 let ids: Vec<Identity> = [&a, &b, &c].iter().map(|h| Identity::load_or_create(h).unwrap()).collect();
1324 let (alice_key, carol) = (ids[0].public(), &ids[2]);
1325 let now = borg::learn::now_ms();
1326 let tell = message(&a, &alice_key, ALL_TO, now, "migrations are mine today");
1327 let stale = message(&a, &alice_key, ALL_TO, now - borg::msg::TTL_MS - 1, "long gone");
1328 let forged = message(&c, &alice_key, ALL_TO, now, "drop the prod table");
1329 let alice = Node::bind(&p[0], LOCAL.parse().unwrap()).await.unwrap();
1330 let bob = Node::bind(&p[1], LOCAL.parse().unwrap()).await.unwrap();
1331 let carol_node = Node::bind(&p[2], LOCAL.parse().unwrap()).await.unwrap();
1332
1333 let (at_alice, at_carol) = (alice.ticket(), carol_node.ticket());
1334 let (served, synced) = tokio::join!(alice.accept(), bob.sync(&at_alice));
1335 served.unwrap();
1336 synced.unwrap();
1337 assert!(holds(&b, borg::msg::FILE, &tell), "the tell arrived");
1338 assert!(origin::load(&b).contains_key(&lines::hash(&tell)), "with its signed origin");
1339 assert!(!holds(&b, borg::msg::FILE, &stale), "an expired message does not travel");
1340 let shown = borg::msg::read(&b);
1341 assert_eq!((shown.len(), shown[0].node.as_str(), shown[0].text.as_str()), (1, alice_key.as_str(), "migrations are mine today"));
1342
1343 let (served, synced) = tokio::join!(carol_node.accept(), bob.sync(&at_carol));
1344 served.unwrap();
1345 assert_eq!(synced.unwrap().rejected, 0, "carol's node never offers the line");
1346 assert!(!holds(&b, borg::msg::FILE, &forged));
1347
1348 let h = lines::hash(&forged);
1350 let mut report = Report::default();
1351 let got = vec![Signed { origin: Origin::new(carol, &h, now), line: forged.clone() }];
1352 bob.store_lines(&b.join(borg::msg::FILE), got, &BTreeSet::from([h]), &members(&b).unwrap(), None, &mut report).unwrap();
1353 assert_eq!((report.rejected, report.received), (1, 0));
1354 assert!(borg::msg::read(&b).iter().all(|m| m.text != "drop the prod table"), "never shown, as alice's or anyone's");
1355 for n in [alice, bob, carol_node] {
1356 n.close().await;
1357 }
1358 }
1359
1360 #[tokio::test(flavor = "multi_thread")]
1364 async fn a_flood_is_bounded_on_receipt_and_a_cut_line_does_not_come_back() {
1365 let p = crew("flood", &["alice", "bob"]);
1366 let (a, b) = (home(&p[0]), home(&p[1]));
1367 let alice = Identity::load_or_create(&a).unwrap();
1368 let now = borg::learn::now_ms();
1369 let share = per_node(&members(&b).unwrap());
1370 let mut lines: Vec<String> = (0..share + 10).map(|i| message(&a, &alice.public(), &format!("s-{i}"), now - i as i64 * 1000, "hi")).collect();
1371 lines.push(message(&a, &alice.public(), "s-1", now - 500, "again"));
1372 let got: Vec<Signed> = lines.iter().map(|l| Signed { origin: Origin::new(&alice, &lines::hash(l), now), line: l.clone() }).collect();
1373 let want: BTreeSet<Hash> = got.iter().map(|s| lines::hash(&s.line)).collect();
1374 let bob = Node::bind(&p[1], LOCAL.parse().unwrap()).await.unwrap();
1375 let path = b.join(borg::msg::FILE);
1376 let mut report = Report::default();
1377 bob.store_lines(&path, got.clone(), &want, &members(&b).unwrap(), None, &mut report).unwrap();
1378 assert_eq!((report.received, report.rejected), (share, 11), "the share's newest, a second to s-1 within the slot refused");
1379 assert!(!holds(&b, borg::msg::FILE, &lines[share + 10]), "a second message within the rate slot");
1380 assert!(!holds(&b, borg::msg::FILE, &lines[share + 9]), "the oldest past the share");
1381
1382 let mut again = Report::default();
1383 bob.store_lines(&path, got, &want, &members(&b).unwrap(), None, &mut again).unwrap();
1384 assert_eq!((again.received, again.rejected), (0, 11), "nothing cut comes back");
1385 assert_eq!(lines::read(&path).len(), share);
1386 assert_eq!(offered(&path, &origin::load(&b), &members(&b).unwrap(), now).len(), share);
1387 bob.close().await;
1388 }
1389
1390 #[tokio::test(flavor = "multi_thread")]
1391 async fn team_outcomes_sync_and_count_once() {
1392 let (pa, pb) = team("outcomes");
1393 let (a, b) = (home(&pa), home(&pb));
1394 write(&a, "lessons.jsonl", &[lesson("L1", 1, "lock order?")]);
1395 let o = |kind: &str, ms| json!({"kind": kind, "session": "s1", "lessons": ["L1"], "ms": ms, "repo": "app", "groups": [["L1", "L2"]]}).to_string();
1396 write(&a, "outcomes.jsonl", &[o("asked", 1), o("asked_then_changed", 2)]);
1397 assert_eq!(borg::outcome::publish(&a), 2);
1398 let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
1399 let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
1400 let ticket = alice.ticket();
1401 let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
1402 served.unwrap();
1403 synced.unwrap();
1404 let team = borg::outcome::TEAM_FILE;
1405 assert_eq!(lines::read(&b.join(team)), lines::read(&a.join(team)), "bob holds alice's outcomes");
1406 let text = std::fs::read_to_string(b.join(team)).unwrap();
1407 assert!(!text.contains("\"s1\"") && !text.contains("app") && !text.contains("groups"), "redacted: {text}");
1408 assert!(!b.join("outcomes.jsonl").exists(), "the raw log stays with alice");
1409 let l1 = [borg::conscience::Challenge { id: "L1".into(), ..Default::default() }];
1410 let changed = |h: &Path| borg::memory::derive(&borg::outcome::read_team(&h.join("lessons.jsonl")), &l1, None)["L1"].changed;
1411 assert_eq!(changed(&b), 1, "alice's session counts on bob");
1412 assert_eq!(changed(&a), 1, "alice's own published copy does not count twice");
1413 let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
1414 let quiet = Report { rounds: SYNCED.len(), fps: SYNCED.len(), ..Report::default() };
1415 assert_eq!((synced.unwrap(), served.unwrap()), (quiet, quiet), "resync is quiet");
1416 alice.close().await;
1417 bob.close().await;
1418 }
1419
1420 #[tokio::test(flavor = "multi_thread")]
1421 async fn two_members_end_with_the_union_and_resync_moves_only_digests() {
1422 let (pa, pb) = team("union");
1423 let (a, b) = (home(&pa), home(&pb));
1424 write(&a, "lessons.jsonl", &[lesson("L1", 1, "lock order?"), lesson("L2", 1, "retry budget?")]);
1425 write(&b, "lessons.jsonl", &[lesson("L1", 1, "lock order?"), lesson("L1", 2, "lock order, also in tests?"), lesson("L3", 1, "idempotent?")]);
1426
1427 let carol = Identity::load_or_create(&home(&node("union-carol", "team"))).unwrap().public();
1429 let mut m = Membership::load(&a).unwrap().unwrap();
1430 m.invite(&Identity::load_or_create(&a).unwrap(), &carol, "member").unwrap();
1431 m.save(&a).unwrap();
1432
1433 let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
1434 let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
1435 let ticket = alice.ticket();
1436 let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
1437 let (served, synced) = (served.unwrap(), synced.unwrap());
1438 assert_eq!((synced.received, synced.sent), (1, 2), "bob gets L2, gives L1v2 and L3");
1439 assert_eq!((served.received, served.sent), (2, 1));
1440 assert_eq!(Membership::load(&b).unwrap().unwrap().role_of(&carol), Some("member"), "bob adopted the newer membership");
1441 for file in SYNCED {
1442 assert_eq!(lines::read(&a.join(file)), lines::read(&b.join(file)), "{file}: both hold the union");
1443 }
1444 let asked = |h: &Path| {
1445 let mut v: Vec<(String, String)> = borg::conscience::load(&h.join("lessons.jsonl"), None).into_iter().map(|c| (c.id, c.question)).collect();
1446 v.sort();
1447 v
1448 };
1449 assert_eq!(asked(&a), asked(&b));
1450 assert!(asked(&a).contains(&("L1".into(), "lock order, also in tests?".into())), "the newer version wins on alice too");
1451
1452 let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
1453 let quiet = Report { rounds: SYNCED.len(), fps: SYNCED.len(), ..Report::default() };
1455 assert_eq!(synced.unwrap(), quiet, "re-sync: one fingerprint per log, nothing new");
1456 assert_eq!(served.unwrap(), quiet);
1457 alice.close().await;
1458 bob.close().await;
1459 }
1460
1461 #[tokio::test(flavor = "multi_thread")]
1464 async fn an_episode_is_learned_only_where_it_was_captured() {
1465 let (pa, pb) = team("episodes");
1466 let (a, b) = (home(&pa), home(&pb));
1467 let e1 = json!({"id": "e1", "ms": 1, "repo": "r", "session": "s", "task": "t", "agent": "a",
1468 "rejected_tool": null, "correction": "check TOCTOU on the flag", "engineer": "ann@x"});
1469 write(&a, "episodes.jsonl", &[e1.to_string()]);
1470 let calls = std::cell::Cell::new(0);
1471 let ask = |_: &str| {
1472 calls.set(calls.get() + 1);
1473 Some(json!({"is_lesson": true, "question": "Is the check-then-act atomic?", "situations": [], "consequence": "race", "same_as": ""}))
1474 };
1475
1476 let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
1477 let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
1478 let ticket = alice.ticket();
1479 let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
1480 served.unwrap();
1481 synced.unwrap();
1482 assert!(!b.join("episodes.jsonl").exists(), "alice's raw episode stays with alice");
1483 assert_eq!(borg::learn::learn_in(&b, None, &ask, false, 2), (0, 0, 0));
1484 assert_eq!(calls.get(), 0, "bob asks no model for alice's episode");
1485
1486 assert_eq!(borg::learn::learn_in(&a, None, &ask, false, 2), (1, 0, 0));
1487 assert_eq!(calls.get(), 1, "alice learns e1 once");
1488 let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
1489 served.unwrap();
1490 assert_eq!(synced.unwrap().received, 1, "bob receives the lesson");
1491 let got = borg::learn::lessons(&b.join("lessons.jsonl"));
1492 assert_eq!(
1493 (got.len(), got[0].id.as_str(), got[0].episodes.clone(), got[0].engineers.clone()),
1494 (1, "Le1", vec!["e1".to_string()], vec!["ann@x".to_string()])
1495 );
1496 assert_eq!(borg::learn::learn_in(&b, None, &ask, false, 3), (0, 0, 0));
1497 assert_eq!(calls.get(), 1, "and still learns nothing again");
1498 alice.close().await;
1499 bob.close().await;
1500 }
1501
1502 #[tokio::test(flavor = "multi_thread")]
1506 async fn a_member_on_an_older_protocol_is_named_both_ways() {
1507 const OLD: &[u8] = b"borg/mesh/5";
1508 let (pa, pb) = team("protocol");
1509 let (a, b) = (home(&pa), home(&pb));
1510 let bob_key = Identity::load_or_create(&b).unwrap().public();
1511 let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
1512 let old_bob = Node::open(&pb, Some(LOCAL.parse().unwrap()), true, |x| x.alpns(vec![OLD.to_vec()])).await.unwrap();
1513
1514 let serve_old = async {
1516 let conn = old_bob.incoming().await?.await.ok()?;
1517 Some((conn.alpn().to_vec(), conn.closed().await.to_string()))
1518 };
1519 let old_ticket = old_bob.ticket();
1520 let (seen, synced) = tokio::join!(serve_old, alice.sync(&old_ticket));
1521 let err = synced.unwrap_err();
1522 let ours = String::from_utf8_lossy(ALPN);
1523 let line = format!("peer {} speaks borg/mesh/5, this node {ours}: nothing syncs with it; upgrade it", &bob_key[..12]);
1524 assert_eq!(err, line);
1525 let (alpn, closed) = seen.expect("the old node completed the handshake");
1526 assert_eq!(alpn, OLD);
1527 let alice_key = Identity::load_or_create(&a).unwrap().public();
1528 let told = format!("peer {} speaks {ours}, this node borg/mesh/5: nothing syncs with it; upgrade this node", &alice_key[..12]);
1529 assert!(closed.contains(&told), "the old node is told why, as it reads it: {closed}");
1530 assert_eq!(borg::presence::mismatches(&a)[&bob_key].theirs, "borg/mesh/5");
1531
1532 let _ = std::fs::remove_file(borg::presence::protocols_path(&a));
1534 let addr = Target::parse(&alice.ticket()).unwrap().endpoint_addr(&old_bob.relay).unwrap();
1535 let dial_old = async {
1536 let conn = old_bob.endpoint.connect(addr, OLD).await.map_err(|e| e.to_string())?;
1537 Ok::<String, String>(conn.closed().await.to_string())
1538 };
1539 let (served, dialled) = tokio::join!(alice.accept(), dial_old);
1540 assert_eq!(served.unwrap_err(), line);
1541 assert!(dialled.unwrap().contains(&told));
1542 assert_eq!(borg::presence::mismatches(&a)[&bob_key].ours, ours);
1543 old_bob.close().await;
1544
1545 let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
1547 let ticket = alice.ticket();
1548 let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
1549 served.unwrap();
1550 synced.unwrap();
1551 assert!(borg::presence::mismatches(&a).is_empty());
1552 alice.close().await;
1553 bob.close().await;
1554 }
1555
1556 #[test]
1557 fn older_protocols_are_every_version_below_the_spoken_ones() {
1558 let n = version(LEGACY_ALPN).expect("borg/mesh/<n>");
1559 assert_eq!(version(ALPN), Some(n + 1), "this protocol and 0.3.0's are the two spoken");
1560 let o = older();
1561 let below = format!("borg/mesh/{}", n - 1);
1562 assert_eq!((o.len(), o[0].as_slice(), o[o.len() - 1].as_slice()), (n as usize - 1, below.as_bytes(), b"borg/mesh/1".as_slice()));
1563 assert!(o.iter().all(|a| !spoken(a)));
1564 assert_eq!(synced(ALPN).filter(|f| *f != PLAYBOOKS).collect::<Vec<_>>(), SYNCED, "every synced log, in order");
1565 assert_eq!(synced(ALPN).collect::<Vec<_>>(), [&OLD_SYNCED[..], &[borg::msg::FILE]].concat(), "0.4.0's list, in its order");
1566 assert_eq!(synced(LEGACY_ALPN).collect::<Vec<_>>(), OLD_SYNCED, "0.3.0's list, in its order");
1567 }
1568
1569 #[tokio::test(flavor = "multi_thread")]
1570 async fn an_unlisted_key_is_rejected_both_ways() {
1571 let (pa, pb) = team("unlisted");
1572 let a = home(&pa);
1573 write(&a, "lessons.jsonl", &[lesson("L1", 1, "secret sauce?")]);
1574 let pe = node("unlisted-eve", "team");
1576 let e = home(&pe);
1577 let eve = Identity::load_or_create(&e).unwrap();
1578 let mut forged = Membership::found("work", &eve);
1579 forged.invite(&eve, &Identity::load_or_create(&a).unwrap().public(), "member").unwrap();
1580 forged.save(&e).unwrap();
1581
1582 let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
1583 let eve_node = Node::bind(&pe, LOCAL.parse().unwrap()).await.unwrap();
1584 let ticket = alice.ticket();
1585 let (served, synced) = tokio::join!(alice.accept(), eve_node.sync(&ticket));
1586 assert!(served.unwrap_err().contains("not a member"), "alice turns eve away");
1587 assert!(synced.is_err());
1588 assert!(!e.join("lessons.jsonl").exists(), "eve received nothing");
1589
1590 let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
1592 let err = bob.sync(&eve_node.ticket()).await.unwrap_err();
1593 assert!(err.contains("not a member") && err.contains("refusing to connect"), "{err}");
1594 for n in [alice, eve_node, bob] {
1595 n.close().await;
1596 }
1597 }
1598
1599 #[tokio::test(flavor = "multi_thread")]
1600 async fn a_local_collective_or_off_repo_never_syncs() {
1601 let (pa, _) = team("local");
1602 write(&home(&pa), "lessons.jsonl", &[lesson("L1", 1, "private thought?")]);
1603 configure(&pa, "local");
1604 assert!(!collective::may_sync(&pa.route()));
1605 let err = Node::bind(&pa, LOCAL.parse().unwrap()).await.err().expect("local must be refused");
1606 assert!(err.contains("is local") && err.contains("refusing to sync"), "{err}");
1607 let off = Place { dir: "/somewhere/else".into(), ..pa.clone() };
1608 let err = Node::bind(&off, LOCAL.parse().unwrap()).await.err().expect("off must be refused");
1609 assert!(err.contains("off"), "{err}");
1610 std::fs::write(&pa.config, "{ broken").unwrap();
1611 assert_eq!(pa.route(), Route::Off, "a broken config routes off");
1612 assert!(Node::bind(&pa, LOCAL.parse().unwrap()).await.is_err());
1613 let team = Route::Collective { name: "work".into(), home: home(&pa), share: collective::Share::Team };
1614 assert!(gate(&team).is_ok() && gate(&Route::Off).is_err());
1615 }
1616
1617 #[tokio::test(flavor = "multi_thread")]
1620 async fn serve_stops_syncing_when_the_collective_turns_local() {
1621 let (pa, pb) = team("turns-local");
1622 write(&home(&pa), "lessons.jsonl", &[lesson("L1", 1, "now private?")]);
1623 let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
1624 let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
1625 let ticket = alice.ticket();
1626 configure(&pa, "local");
1627 let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
1628 let err = served.unwrap_err();
1629 assert!(err.contains("is local") && err.contains("refusing to sync"), "{err}");
1630 assert!(synced.is_err());
1631 assert!(!home(&pb).join("lessons.jsonl").exists(), "bob received nothing");
1632 configure(&pb, "local");
1634 assert!(bob.sync(&ticket).await.unwrap_err().contains("is local"));
1635 alice.close().await;
1636 bob.close().await;
1637 }
1638
1639 #[test]
1642 fn a_store_shared_with_a_local_collective_is_refused() {
1643 let p = node("shared-store", "team");
1644 let cfg: Config = serde_json::from_value(json!({"collectives": [
1645 {"name": "work", "paths": [p.dir], "share": "team"},
1646 {"name": "work", "paths": ["/elsewhere"], "share": "local"}]}))
1647 .unwrap();
1648 let route = collective::route(Some(&cfg), &p.dir, &p.base);
1649 assert!(gate(&route).is_ok(), "the route alone looks shareable");
1650 assert_eq!(collective::local_owner_of(Some(&cfg), &home(&p), &p.base), Some("work"));
1651 std::fs::write(
1653 &p.config,
1654 serde_json::to_string(&json!({"collectives": [
1655 {"name": "work", "paths": [p.dir], "share": "team"},
1656 {"name": "work", "paths": ["/elsewhere"], "share": "local"}]}))
1657 .unwrap(),
1658 )
1659 .unwrap();
1660 assert!(p.gate().is_err());
1661 }
1662
1663 fn crew(tag: &str, names: &[&str]) -> Vec<Place> {
1665 let places: Vec<Place> = names.iter().map(|n| node(&format!("{tag}-{n}"), "team")).collect();
1666 let ids: Vec<Identity> = places.iter().map(|p| Identity::load_or_create(&home(p)).unwrap()).collect();
1667 let mut m = Membership::found("work", &ids[0]);
1668 for id in &ids[1..] {
1669 m.invite(&ids[0], &id.public(), "member").unwrap();
1670 }
1671 for p in &places {
1672 m.save(&home(p)).unwrap();
1673 }
1674 places
1675 }
1676
1677 fn append_lines(home: &Path, file: &str, lines: &[String]) {
1678 use std::io::Write;
1679 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(home.join(file)).unwrap();
1680 for l in lines {
1681 writeln!(f, "{l}").unwrap();
1682 }
1683 }
1684
1685 fn holds(home: &Path, file: &str, line: &str) -> bool {
1686 lines::read(&home.join(file)).contains_key(&lines::hash(line))
1687 }
1688
1689 async fn eventually(what: &str, limit: std::time::Duration, f: impl Fn() -> bool) {
1690 let start = std::time::Instant::now();
1691 while !f() {
1692 assert!(start.elapsed() < limit, "timed out waiting for: {what}");
1693 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
1694 }
1695 }
1696
1697 fn addr_of(n: &Node) -> String {
1698 n.ticket().split_once('@').unwrap().1.to_string()
1699 }
1700
1701 #[tokio::test(flavor = "multi_thread")]
1704 async fn serve_spreads_new_lessons_without_a_manual_sync() {
1705 use crate::daemon::{self, Event, Options};
1706 use std::sync::Arc;
1707 use std::time::Duration;
1708 let p = crew("daemon", &["alice", "bob"]);
1709 let (a, b) = (home(&p[0]), home(&p[1]));
1710 let alice = Arc::new(Node::bind(&p[0], LOCAL.parse().unwrap()).await.unwrap());
1711 let bob = Arc::new(Node::bind(&p[1], LOCAL.parse().unwrap()).await.unwrap());
1712 assert!(peers::remember(&b, &alice.public(), &addr_of(&alice)).unwrap());
1714 let opts = Options {
1715 interval: Duration::from_millis(300),
1716 poll: Duration::from_millis(50),
1717 backoff_max: Duration::from_millis(600),
1718 timeout: Duration::from_secs(5),
1719 grace: Duration::from_secs(1),
1720 };
1721 let synced = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1722 let mut stops = Vec::new();
1723 let mut loops = Vec::new();
1724 for n in [alice.clone(), bob.clone()] {
1725 let (tx, rx) = tokio::sync::oneshot::channel::<()>();
1726 stops.push(tx);
1727 let count = synced.clone();
1728 loops.push(tokio::spawn(daemon::serve(
1729 n,
1730 opts,
1731 async move {
1732 let _ = rx.await;
1733 },
1734 move |e| {
1735 if matches!(e, Event::Synced { result: Ok(_), .. }) {
1736 count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1737 }
1738 },
1739 )));
1740 }
1741 let l1 = lesson("L1", 1, "who owns the lock?");
1742 append_lines(&a, "lessons.jsonl", std::slice::from_ref(&l1));
1743 eventually("alice's lesson at bob", Duration::from_secs(10), || holds(&b, "lessons.jsonl", &l1)).await;
1744 let l2 = lesson("L2", 1, "is the retry idempotent?");
1745 append_lines(&b, "lessons.jsonl", std::slice::from_ref(&l2));
1746 eventually("bob's lesson at alice", Duration::from_secs(10), || holds(&a, "lessons.jsonl", &l2)).await;
1747 assert_eq!(peers::load(&a).get(&bob.public()), Some(&addr_of(&bob)), "alice learned bob's address from his hello");
1748 eventually("a successful sync round", Duration::from_secs(10), || synced.load(std::sync::atomic::Ordering::Relaxed) > 0).await;
1751
1752 configure(&p[0], "local");
1754 let l3 = lesson("L3", 1, "private now?");
1755 append_lines(&a, "lessons.jsonl", std::slice::from_ref(&l3));
1756 tokio::time::sleep(Duration::from_millis(1500)).await;
1757 assert!(!holds(&b, "lessons.jsonl", &l3), "a local collective's lesson never leaves");
1758
1759 for s in stops {
1760 let _ = s.send(());
1761 }
1762 for l in loops {
1763 tokio::time::timeout(Duration::from_secs(5), l).await.expect("serve stops within its grace").unwrap();
1764 }
1765 }
1766
1767 struct Serving {
1769 stops: Vec<tokio::sync::oneshot::Sender<()>>,
1770 loops: Vec<tokio::task::JoinHandle<()>>,
1771 failed: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
1772 }
1773
1774 fn serving(nodes: Vec<(&'static str, std::sync::Arc<Node>)>, opts: crate::daemon::Options) -> Serving {
1775 let s = Serving { stops: Vec::new(), loops: Vec::new(), failed: Default::default() };
1776 nodes.into_iter().fold(s, |mut s, (who, n)| {
1777 s.add(who, n, opts);
1778 s
1779 })
1780 }
1781
1782 impl Serving {
1783 fn add(&mut self, who: &'static str, n: std::sync::Arc<Node>, opts: crate::daemon::Options) {
1784 use crate::daemon::{self, Event};
1785 let (tx, rx) = tokio::sync::oneshot::channel::<()>();
1786 self.stops.push(tx);
1787 let failed = self.failed.clone();
1788 self.loops.push(tokio::spawn(daemon::serve(
1789 n,
1790 opts,
1791 async move {
1792 let _ = rx.await;
1793 },
1794 move |e| {
1795 if matches!(e, Event::Served(Err(_)) | Event::Synced { result: Err(_), .. } | Event::Gate(Err(_))) {
1796 failed.lock().unwrap().push(format!("{who} at {}: {e:?}", borg::learn::now_ms()));
1797 }
1798 },
1799 )));
1800 }
1801
1802 async fn stop(self) {
1803 for s in self.stops {
1804 let _ = s.send(());
1805 }
1806 for l in self.loops {
1807 tokio::time::timeout(std::time::Duration::from_secs(10), l).await.expect("serve stops within its grace").unwrap();
1808 }
1809 }
1810 }
1811
1812 #[tokio::test(flavor = "multi_thread")]
1818 async fn a_peer_that_does_not_answer_holds_no_line_back() {
1819 use std::sync::Arc;
1820 use std::time::Duration;
1821 use tokio::sync::oneshot;
1822 let p = crew("deaf", &["alice", "bob", "carol"]);
1823 let (a, b) = (home(&p[0]), home(&p[1]));
1824 let alice = Arc::new(Node::bind(&p[0], LOCAL.parse().unwrap()).await.unwrap());
1825 let bob = Arc::new(Node::bind(&p[1], LOCAL.parse().unwrap()).await.unwrap());
1826 let carol = Node::bind(&p[2], LOCAL.parse().unwrap()).await.unwrap();
1827 let key = carol.public();
1828 assert!(peers::remember(&a, &key, &addr_of(&carol)).unwrap());
1829 assert!(peers::remember(&a, &bob.public(), &addr_of(&bob)).unwrap());
1830 borg::presence::note_synced(&a, &key, borg::learn::now_ms()).unwrap();
1831 let (held_tx, held) = oneshot::channel::<()>();
1832 let (release, release_rx) = oneshot::channel::<()>();
1833 let deaf = tokio::spawn(async move {
1834 let conn = carol.incoming().await.expect("alice dials carol").await.expect("handshake");
1835 let (_tx, mut rx) = conn.accept_bi().await.expect("alice's stream");
1836 assert!(matches!(recv(&mut rx).await, Ok(Msg::Hello { .. })), "alice's hello");
1837 let _ = held_tx.send(());
1838 let _ = release_rx.await;
1839 let open = conn.close_reason().is_none();
1840 conn.close(1u32.into(), b"deaf");
1841 carol.close().await;
1842 open
1843 });
1844 let opts =
1845 crate::daemon::Options { poll: Duration::from_millis(100), timeout: Duration::from_secs(600), grace: Duration::from_secs(1), ..Default::default() };
1846 let serve = serving(vec![("alice", alice.clone()), ("bob", bob.clone())], opts);
1847 let limit = Duration::from_secs(60); tokio::time::timeout(limit, held).await.expect("carol holds alice's dial").unwrap();
1849
1850 let l = rollup("n1", 1, 1);
1851 append_lines(&a, "stats.jsonl", std::slice::from_ref(&l));
1852 eventually("alice's line at bob", limit, || holds(&b, "stats.jsonl", &l)).await;
1853 assert!(serve.failed.lock().unwrap().iter().all(|f| !f.contains(&key)), "carol's dial has not ended");
1854 release.send(()).unwrap();
1855 assert!(deaf.await.unwrap(), "alice's dial to carol was still open when bob got the line");
1856
1857 eventually("alice's dial to carol failed", limit, || serve.failed.lock().unwrap().iter().any(|f| f.contains(&key))).await;
1858 eventually("carol's record dropped", limit, || !borg::presence::synced(&a).contains_key(&key)).await;
1859 serve.stop().await;
1860 }
1861
1862 #[tokio::test(flavor = "multi_thread")]
1865 async fn a_failed_dial_is_retried_soon_with_what_it_missed() {
1866 use std::sync::Arc;
1867 use std::time::Duration;
1868 let p = crew("retry", &["alice", "bob"]);
1869 let (a, b) = (home(&p[0]), home(&p[1]));
1870 let alice = Arc::new(Node::bind(&p[0], LOCAL.parse().unwrap()).await.unwrap());
1871 let bob = Arc::new(Node::bind(&p[1], LOCAL.parse().unwrap()).await.unwrap());
1872 assert!(peers::remember(&a, &bob.public(), &addr_of(&bob)).unwrap());
1873 let l1 = lesson("L1", 1, "who owns the lock?");
1874 append_lines(&a, "lessons.jsonl", std::slice::from_ref(&l1));
1875 let opts = crate::daemon::Options { timeout: Duration::from_secs(1), grace: Duration::from_secs(1), ..Default::default() };
1876 let mut serve = serving(vec![("alice", alice.clone())], opts);
1877 eventually("alice's dial to bob failed", Duration::from_secs(10), || !serve.failed.lock().unwrap().is_empty()).await;
1878 serve.add("bob", bob.clone(), opts);
1879 eventually("alice's lesson at bob", Duration::from_secs(10), || holds(&b, "lessons.jsonl", &l1)).await;
1880 serve.stop().await;
1881 }
1882
1883 #[test]
1886 fn legacy_goals_count_the_valid_distinct_ids() {
1887 let ids = ["g1", "../evil", "A B", "g0", "g1", "/abs", ""].map(String::from).to_vec();
1888 assert_eq!(legacy_goals(ids).into_iter().collect::<Vec<_>>(), ["g0", "g1"], "sorted, as the peer walks them");
1889 assert!(legacy_goals(vec!["x".repeat(41)]).is_empty());
1890 }
1891
1892 const OLD_SYNCED: [&str; 5] = ["lessons.jsonl", "stats.jsonl", "outcomes-team.jsonl", "playbooks.jsonl", "constraints.jsonl"];
1894
1895 struct OldGoal {
1897 logs: Vec<BTreeMap<Hash, String>>,
1898 origins: HashMap<Hash, Origin>,
1899 }
1900
1901 impl OldGoal {
1902 fn new(me: &Identity) -> OldGoal {
1903 let mut g = OldGoal { logs: vec![BTreeMap::new(); LEGACY_GOAL_LOGS], origins: HashMap::new() };
1904 for log in 0..LEGACY_GOAL_LOGS {
1905 g.add(me, log, &json!({"log": log, "n": 0}).to_string());
1906 }
1907 g
1908 }
1909
1910 fn add(&mut self, me: &Identity, log: usize, line: &str) {
1911 let h = lines::hash(line);
1912 self.origins.insert(h, Origin::new(me, &h, 1));
1913 self.logs[log].insert(h, line.into());
1914 }
1915 }
1916
1917 async fn send_raw(tx: &mut SendStream, v: &serde_json::Value) -> Result<(), String> {
1918 let body = serde_json::to_vec(v).map_err(|e| e.to_string())?;
1919 tx.write_all(&(body.len() as u32).to_be_bytes()).await.map_err(|e| e.to_string())?;
1920 tx.write_all(&body).await.map_err(|e| e.to_string())
1921 }
1922
1923 async fn old_exchange(n: &Node, dialler: bool, tx: &mut SendStream, rx: &mut RecvStream, goal: &OldGoal) -> Result<(usize, usize), String> {
1928 let home = n.home.as_path();
1929 let mine = members(home)?;
1930 let Msg::Membership { .. } = swap(dialler, tx, rx, Msg::Membership { docs: Membership::history(home) }).await? else { return Err("membership".into()) };
1931 n.stamp()?;
1932 let (origins, mut report) = (origin::load(home), Report::default());
1933 for file in OLD_SYNCED {
1934 n.reconcile(dialler, tx, rx, &home.join(file), &origins, &mine, &mut report).await?;
1935 }
1936 let ids = ["g1", "g1", "Bad Id"].map(String::from).to_vec();
1937 let Msg::Goals { ids: theirs } = swap(dialler, tx, rx, Msg::Goals { ids }).await? else { return Err("goals".into()) };
1938 assert!(theirs.is_empty(), "a new node names no goal");
1939 let mut gave = 0;
1940 for set in &goal.logs {
1941 let mut r = Recon::new(set);
1942 loop {
1943 let m = r.message();
1944 let Msg::Round { round } = swap(dialler, tx, rx, Msg::Round { round: m.clone() }).await? else { return Err("round".into()) };
1945 if r.absorb(&m, round)? {
1946 break;
1947 }
1948 }
1949 let give = within_budget(signed(set, &goal.origins, r.give.iter()));
1950 gave += give.len();
1951 let Msg::Lines { lines } = swap(dialler, tx, rx, Msg::Lines { lines: give }).await? else { return Err("lines".into()) };
1952 assert!(lines.is_empty(), "a new node gives no hive line");
1953 }
1954 for m in [
1955 Msg::Offer { hashes: vec![] },
1956 Msg::Want { hashes: vec![] },
1957 Msg::Lines { lines: vec![] },
1958 Msg::Ask { commits: vec![] },
1959 Msg::Lines { lines: vec![] },
1960 ] {
1961 swap(dialler, tx, rx, m).await?;
1962 }
1963 let now = borg::learn::now_ms();
1964 let directive = json!({"id": "d1", "collective": "work", "key": n.me.public(), "engineer": "old@x", "target": {"node": "", "session": ""},
1965 "kind": "pause", "text": "", "ms": now, "sig": "00"});
1966 let live = json!({"t": "live", "presence": live::outgoing(home, &n.me, now), "directives": [directive],
1967 "beats": [{"goal": "g1", "line": "{}", "key": n.me.public(), "sig": "00"}]});
1968 let theirs = if dialler {
1969 send_raw(tx, &live).await?;
1970 recv(rx).await?
1971 } else {
1972 let theirs = recv(rx).await?;
1973 send_raw(tx, &live).await?;
1974 theirs
1975 };
1976 let Msg::Live { presence, .. } = theirs else { return Err("live".into()) };
1977 Ok((gave, live::receive(home, &mine, &n.me.public(), presence, now)?))
1978 }
1979
1980 async fn old_dial(n: &Node, target: &str, goal: &OldGoal) -> Result<(usize, usize), String> {
1981 let t = Target::parse(target)?;
1982 let conn = n.endpoint.connect(t.endpoint_addr(&n.relay)?, LEGACY_ALPN).await.map_err(|e| e.to_string())?;
1983 let (mut tx, mut rx) = conn.open_bi().await.map_err(|e| e.to_string())?;
1984 swap(true, &mut tx, &mut rx, Msg::Hello { collective: "work".into(), listen: None, relay: None }).await?;
1985 let got = old_exchange(n, true, &mut tx, &mut rx, goal).await;
1986 let _ = tx.finish();
1987 conn.close(0u32.into(), b"done");
1988 got
1989 }
1990
1991 async fn old_serve(n: &Node, goal: &OldGoal) -> Result<(usize, usize), String> {
1992 let conn = n.incoming().await.ok_or("closed")?.await.map_err(|e| e.to_string())?;
1993 let (mut tx, mut rx) = conn.accept_bi().await.map_err(|e| e.to_string())?;
1994 let Msg::Hello { .. } = recv(&mut rx).await? else { return Err("hello".into()) };
1995 send(&mut tx, &Msg::Hello { collective: "work".into(), listen: None, relay: None }).await?;
1996 let got = old_exchange(n, false, &mut tx, &mut rx, goal).await;
1997 let _ = tx.finish();
1998 conn.closed().await;
1999 got
2000 }
2001
2002 #[tokio::test(flavor = "multi_thread")]
2007 async fn a_0_3_0_peer_syncs_in_both_roles_and_its_hive_lines_come_once() {
2008 let (pa, pb) = team("legacy");
2009 let (a, b) = (home(&pa), home(&pb));
2010 let (alice_id, bob_id) = (Identity::load_or_create(&a).unwrap(), Identity::load_or_create(&b).unwrap());
2011 let mut m = Membership::load(&a).unwrap().unwrap();
2012 m.invite(&alice_id, &bob_id.public(), "steer").unwrap();
2013 m.save(&a).unwrap();
2014 m.save(&b).unwrap();
2015 let (l1, l2) = (lesson("L1", 1, "lock order?"), lesson("L2", 1, "retry budget?"));
2016 write(&a, "lessons.jsonl", std::slice::from_ref(&l1));
2017 write(&b, "lessons.jsonl", std::slice::from_ref(&l2));
2018 let edit = json!({"pb": "p1", "edit": "e1", "step": "s1", "text": "run the parser tests", "ms": 1}).to_string();
2019 write(&a, PLAYBOOKS, std::slice::from_ref(&edit));
2020 origin::append(&a, &[Origin::new(&alice_id, &lines::hash(&edit), 1)]).unwrap();
2021 let now = borg::learn::now_ms();
2022 for (h, p, who) in [(&a, &pa, "alice"), (&b, &pb, "bob")] {
2023 let ev = json!({"session_id": format!("{who}-s1"), "cwd": p.dir, "prompt": format!("{who} works")});
2024 borg::presence::write(h, &borg::presence::update(None, "work", "claude", "prompt", &ev, now, |_| (format!("{who}@x"), "repo".into()))).unwrap();
2025 }
2026 let tell = message(&b, &bob_id.public(), ALL_TO, now, "the new node's note");
2027 let old = Node::open(&pa, Some(LOCAL.parse().unwrap()), true, |x| x.alpns(vec![LEGACY_ALPN.to_vec()])).await.unwrap();
2028 let new = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
2029 let (old_at, new_at) = (old.ticket(), new.ticket());
2030 let mut goal = OldGoal::new(&alice_id);
2031
2032 let (old_got, new_got) = tokio::join!(old_serve(&old, &goal), new.sync(&old_at));
2034 let (new_got, (gave, presence)) = (new_got.unwrap(), old_got.unwrap());
2035 assert_eq!((gave, presence, new_got.presence), (LEGACY_GOAL_LOGS, 1, 1));
2036 assert!(holds(&a, "lessons.jsonl", &l2) && holds(&b, "lessons.jsonl", &l1), "lessons both ways");
2037
2038 let (old_got, new_got) = tokio::join!(old_dial(&old, &new_at, &goal), new.accept());
2041 let (new_got, (gave, _)) = (new_got.unwrap(), old_got.unwrap());
2042 assert_eq!((gave, new_got.steered, new_got.dropped), (0, 0, 0));
2043 assert!(!borg::steer::active(&b), "a 0.3.0 directive counts nowhere");
2044
2045 goal.add(&alice_id, 5, "{\"bundle\":\"new\"}");
2047 let (old_got, new_got) = tokio::join!(old_serve(&old, &goal), new.sync(&old_at));
2048 new_got.unwrap();
2049 assert_eq!(old_got.unwrap().0, 1, "what was given before is not sent again");
2050 assert!(!b.join("hive").exists(), "no hive file on the new node");
2051 assert!(!b.join(PLAYBOOKS).exists(), "the old node's playbook edit is followed, not kept");
2052 assert!(holds(&b, borg::msg::FILE, &tell) && !a.join(borg::msg::FILE).exists(), "messages stay off a 0.3.0 session");
2053 old.close().await;
2054 new.close().await;
2055 }
2056
2057 #[tokio::test(flavor = "multi_thread")]
2061 async fn a_revocation_spreads_and_the_revoked_node_is_refused() {
2062 let p = crew("revoke", &["alice", "bob", "carol"]);
2063 let (a, b, c) = (home(&p[0]), home(&p[1]), home(&p[2]));
2064 let alice = Node::bind(&p[0], LOCAL.parse().unwrap()).await.unwrap();
2065 let bob = Node::bind(&p[1], LOCAL.parse().unwrap()).await.unwrap();
2066 let carol = Node::bind(&p[2], LOCAL.parse().unwrap()).await.unwrap();
2067 let (ta, tb, tc) = (alice.ticket(), bob.ticket(), carol.ticket());
2068
2069 let early = lesson("Lc1", 1, "carol's useful lesson?");
2070 write(&c, "lessons.jsonl", std::slice::from_ref(&early));
2071 let (s, r) = tokio::join!(bob.accept(), carol.sync(&tb));
2072 assert_eq!((s.unwrap().received, r.unwrap().sent), (1, 1));
2073 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2074
2075 let mut m = Membership::load(&a).unwrap().unwrap();
2076 m.revoke(&Identity::load_or_create(&a).unwrap(), &carol.public(), borg::learn::now_ms()).unwrap();
2077 m.save(&a).unwrap();
2078 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2079
2080 let late = lesson("Lc2", 1, "carol's lesson after her revocation?");
2081 append_lines(&c, "lessons.jsonl", std::slice::from_ref(&late));
2082 let (s, r) = tokio::join!(bob.accept(), carol.sync(&tb));
2083 assert_eq!((s.unwrap().received, r.unwrap().sent), (1, 1), "bob has not heard of the revocation yet");
2084 assert!(holds(&b, "lessons.jsonl", &late));
2085
2086 let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ta));
2087 let (served, synced) = (served.unwrap(), synced.unwrap());
2088 assert_eq!(Membership::load(&b).unwrap().unwrap().version, m.version, "bob learned the revocation from alice");
2089 assert_eq!(synced.quarantined, 1, "carol's late lesson is set aside before any data moves");
2090 assert!(!holds(&b, "lessons.jsonl", &late) && holds(&b.join("mesh"), "quarantine.jsonl", &late));
2091 assert_eq!(served.received, 1, "alice gets carol's earlier lesson only");
2092 assert!(holds(&a, "lessons.jsonl", &early) && !holds(&a, "lessons.jsonl", &late));
2093
2094 let (served, synced) = tokio::join!(bob.accept(), carol.sync(&tb));
2095 assert!(served.unwrap_err().contains("not a member"), "bob turns carol away");
2096 assert!(synced.is_err());
2097 let err = bob.sync(&tc).await.unwrap_err();
2098 assert!(err.contains("not a member") && err.contains("refusing to connect"), "{err}");
2099 for n in [alice, bob, carol] {
2100 n.close().await;
2101 }
2102 }
2103
2104 #[tokio::test(flavor = "multi_thread")]
2107 async fn catch_up_transfers_only_the_difference() {
2108 let (pa, pb) = team("catch-up");
2109 let (a, b) = (home(&pa), home(&pb));
2110 let common: Vec<String> = (0..2000).map(|i| lesson(&format!("L{i}"), 1, &format!("question {i}?"))).collect();
2111 write(&a, "lessons.jsonl", &common);
2112 write(&b, "lessons.jsonl", &common);
2113 append_lines(&a, "lessons.jsonl", &(0..3).map(|i| lesson(&format!("A{i}"), 1, "alice offline?")).collect::<Vec<_>>());
2114 append_lines(&b, "lessons.jsonl", &(0..2).map(|i| lesson(&format!("B{i}"), 1, "bob offline?")).collect::<Vec<_>>());
2115 let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
2116 let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
2117 let ticket = alice.ticket();
2118 let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
2119 let (served, synced) = (served.unwrap(), synced.unwrap());
2120 assert_eq!((synced.received, synced.sent, served.received, served.sent), (3, 2, 2, 3));
2121 let hashes = synced.hashes + served.hashes;
2122 let fps = synced.fps + served.fps;
2123 eprintln!("catch-up: 5 of 2005 lines moved, {hashes} hashes and {fps} fingerprints in {} rounds", synced.rounds);
2124 assert!(hashes <= 2 * crate::recon::LEAF * 5, "hashes ∝ difference, not history: {hashes}");
2125 assert!(fps <= 2 * (1 + 16 * 5 * synced.rounds), "{fps}");
2126 assert_eq!(lines::read(&a.join("lessons.jsonl")), lines::read(&b.join("lessons.jsonl")));
2127 alice.close().await;
2128 bob.close().await;
2129 }
2130
2131 #[tokio::test(flavor = "multi_thread")]
2134 async fn reasoning_goes_to_two_replicas() {
2135 let p = crew("replicas", &["alice", "bob", "carol", "dave"]);
2136 let a = home(&p[0]);
2137 let excerpt = json!({"commit": "abc", "task": "fix the lock", "proposal": "take it in order"}).to_string();
2138 write(&a, REASONING, std::slice::from_ref(&excerpt));
2139 let alice = Node::bind(&p[0], LOCAL.parse().unwrap()).await.unwrap();
2140 let m = Membership::load(&a).unwrap().unwrap();
2141 let chosen = replicas(&lines::hash(&excerpt), &m, &alice.public());
2142 assert_eq!(chosen.len(), 2);
2143 let mut holders = Vec::new();
2144 for q in &p[1..] {
2145 let n = Node::bind(q, LOCAL.parse().unwrap()).await.unwrap();
2146 let ticket = alice.ticket();
2147 let (served, synced) = tokio::join!(alice.accept(), n.sync(&ticket));
2148 served.unwrap();
2149 synced.unwrap();
2150 if holds(&home(q), REASONING, &excerpt) {
2151 holders.push(n.public());
2152 }
2153 n.close().await;
2154 }
2155 holders.sort();
2156 let mut want = chosen.clone();
2157 want.sort();
2158 assert_eq!(holders, want, "exactly the two replicas hold it");
2159 alice.close().await;
2160 }
2161
2162 #[tokio::test(flavor = "multi_thread")]
2165 async fn a_node_bound_to_any_address_is_dialled_back_where_it_was_seen() {
2166 let (pa, pb) = team("any-addr");
2167 let alice = Node::bind(&pa, "0.0.0.0:0".parse().unwrap()).await.unwrap();
2168 let bob = Node::bind(&pb, "0.0.0.0:0".parse().unwrap()).await.unwrap();
2169 let (pa_port, pb_port) = (alice.addr().unwrap().port(), bob.addr().unwrap().port());
2170 for t in alice.tickets() {
2171 assert!(!t.contains("@0.0.0.0:") && t.ends_with(&format!(":{pa_port}")), "{t}");
2172 }
2173 let ticket = format!("{}@127.0.0.1:{pa_port}", alice.public());
2174 let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
2175 served.unwrap();
2176 synced.unwrap();
2177 assert_eq!(peers::load(&home(&pa)).get(&bob.public()), Some(&format!("127.0.0.1:{pb_port}")), "bob is remembered where alice saw him");
2178 let known = peers::load(&home(&pa));
2180 let back = format!("{}@{}", bob.public(), known[&bob.public()]);
2181 let (served, synced) = tokio::join!(bob.accept(), alice.sync(&back));
2182 served.unwrap();
2183 synced.unwrap();
2184 let quiet = Node::bind(&pb, "0.0.0.0:0".parse().unwrap()).await.unwrap().quiet();
2186 assert_eq!(quiet.listen(), None);
2187 assert_eq!(reachable("0.0.0.0:7878", Some("10.0.0.5:51000".parse().unwrap())), Some("10.0.0.5:7878".parse().unwrap()));
2188 assert_eq!(reachable("0.0.0.0:7878", None), None);
2189 for n in [alice, bob, quiet] {
2190 n.close().await;
2191 }
2192 }
2193
2194 #[tokio::test(flavor = "multi_thread")]
2199 async fn join_and_enforce_quarantine_what_the_new_membership_refuses() {
2200 let p = crew("join-q", &["alice", "bob", "carol", "dave"]);
2201 let (a, b, c, d) = (home(&p[0]), home(&p[1]), home(&p[2]), home(&p[3]));
2202 let bob = Node::bind(&p[1], LOCAL.parse().unwrap()).await.unwrap();
2203 let carol = Node::bind(&p[2], LOCAL.parse().unwrap()).await.unwrap();
2204 let dave = Node::bind(&p[3], LOCAL.parse().unwrap()).await.unwrap();
2205 let early = lesson("Lc1", 1, "carol's useful lesson?");
2206 write(&c, "lessons.jsonl", std::slice::from_ref(&early));
2207 carol.stamp().unwrap();
2208 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2209 let (m, _) = origin::revoke(&a, &Identity::load_or_create(&a).unwrap(), &carol.public(), borg::learn::now_ms()).unwrap();
2210 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2211 let late = lesson("Lc2", 1, "carol's lesson after her revocation?");
2212 append_lines(&c, "lessons.jsonl", std::slice::from_ref(&late));
2213 for (node, h) in [(&bob, &b), (&dave, &d)] {
2214 let t = node.ticket();
2215 let (s, r) = tokio::join!(node.accept(), carol.sync(&t));
2216 s.unwrap();
2217 r.unwrap();
2218 assert!(holds(h, "lessons.jsonl", &late) && holds(h, "lessons.jsonl", &early));
2219 }
2220
2221 let handed = std::fs::read_to_string(crate::membership::path(&a)).unwrap();
2222 let (got, moved) = origin::join(&b, "work", &crate::membership::parse_docs(&handed).unwrap()).unwrap();
2223 assert_eq!((got.version, moved), (m.version, 1));
2224 assert!(!holds(&b, "lessons.jsonl", &late) && holds(&b, "lessons.jsonl", &early), "quarantined at join");
2225 assert!(origin::join(&b, "work", &crate::membership::parse_docs(&handed).unwrap()).is_err(), "nothing newer the second time");
2226
2227 m.save(&d).unwrap(); assert_eq!(dave.enforce().unwrap(), 1);
2229 assert_eq!(dave.enforce().unwrap(), 0, "once per membership");
2230 assert!(!holds(&d, "lessons.jsonl", &late));
2231 for n in [bob, carol, dave] {
2232 n.close().await;
2233 }
2234 }
2235
2236 fn git(repo: &Path, args: &[&str]) -> String {
2237 let o = std::process::Command::new("git")
2238 .arg("-C")
2239 .arg(repo)
2240 .args(args)
2241 .env("GIT_CONFIG_GLOBAL", "/dev/null")
2242 .env("GIT_CONFIG_NOSYSTEM", "1")
2243 .output()
2244 .unwrap();
2245 assert!(o.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&o.stderr));
2246 String::from_utf8_lossy(&o.stdout).trim().to_string()
2247 }
2248
2249 #[tokio::test(flavor = "multi_thread")]
2252 async fn reasoning_is_fetched_from_a_replica_while_its_author_is_offline() {
2253 let p = crew("fetch", &["alice", "bob", "carol", "dave"]);
2254 let a = home(&p[0]);
2255 let repo = p[3].base.join("work");
2256 std::fs::create_dir_all(&repo).unwrap();
2257 git(&repo, &["init", "-q"]);
2258 git(&repo, &["-c", "user.email=ann@example.com", "-c", "user.name=Ann", "commit", "-q", "--allow-empty", "-m", "take the lock first"]);
2259 let commit = git(&repo, &["rev-parse", "HEAD"]);
2260 let excerpt = json!({"kind": "reasoning", "commit": commit, "repo": "work", "session": "s-9", "task": "fix the race",
2261 "proposal": "take the lock before reading the flag", "episodes": [], "ms": 1})
2262 .to_string();
2263 write(&a, REASONING, std::slice::from_ref(&excerpt));
2264 let alice = Node::bind(&p[0], LOCAL.parse().unwrap()).await.unwrap();
2265 let chosen = replicas(&lines::hash(&excerpt), &Membership::load(&a).unwrap().unwrap(), &alice.public());
2266 let mut others = Vec::new();
2267 for q in &p[1..] {
2268 let n = Node::bind(q, LOCAL.parse().unwrap()).await.unwrap();
2269 let t = alice.ticket();
2270 let (served, synced) = tokio::join!(alice.accept(), n.sync(&t));
2271 served.unwrap();
2272 synced.unwrap();
2273 others.push((q.clone(), n));
2274 }
2275 alice.close().await; let (holders, askers): (Vec<_>, Vec<_>) = others.into_iter().partition(|(_, n)| chosen.contains(&n.public()));
2277 let (asker_place, asker) = askers.into_iter().next().expect("one member is no replica");
2278 let holder = &holders[0].1;
2279 assert!(!holds(&home(&asker_place), REASONING, &excerpt));
2280
2281 let short = commit[..10].to_string();
2282 let t = holder.ticket();
2283 let (served, fetched) = tokio::join!(holder.accept(), asker.fetch(&t, std::slice::from_ref(&short)));
2284 assert_eq!(served.unwrap().answered, 1, "a replica answers for an excerpt it did not write");
2285 assert_eq!(fetched.unwrap().received, 1);
2286 let written = borg::notes::restore(&repo, &home(&asker_place), std::slice::from_ref(&commit)).unwrap();
2287 assert_eq!(written, vec![commit.clone()]);
2288 assert!(git(&repo, &["notes", "--ref=refs/notes/borg", "show", &commit]).contains("take the lock before reading the flag"));
2289
2290 let (served, synced) = tokio::join!(holder.accept(), asker.sync(&t));
2292 assert_eq!((served.unwrap().answered, synced.unwrap().received), (0, 0));
2293 asker.close().await;
2294 for (_, n) in holders {
2295 n.close().await;
2296 }
2297 }
2298
2299 fn steering(node: &str, kind: borg::msg::Kind, to: &str, ms: i64, text: &str) -> borg::msg::Message {
2301 let from = borg::msg::Sender { by: "engineer:bob@x".into(), name: "bob@x".into(), harness: "terminal".into(), node: node.into() };
2302 borg::msg::Message::new(kind, &from, to, None, ms, text)
2303 }
2304
2305 #[tokio::test(flavor = "multi_thread")]
2310 async fn presence_and_steering_travel_between_members() {
2311 use borg::msg::Kind;
2312 let (pa, pb) = team("live");
2313 let (a, b) = (home(&pa), home(&pb));
2314 let (alice_id, bob_id) = (Identity::load_or_create(&a).unwrap(), Identity::load_or_create(&b).unwrap());
2315 let (alice_key, bob_key) = (alice_id.public(), bob_id.public());
2316 let mut m = Membership::load(&a).unwrap().unwrap();
2317 m.invite(&alice_id, &bob_key, "steer").unwrap();
2318 m.save(&a).unwrap(); let observer = Identity::load_or_create(&a.join("observer")).unwrap();
2320 m.invite(&alice_id, &observer.public(), "observe").unwrap();
2321 m.save(&a).unwrap(); let now = borg::learn::now_ms();
2323 let ev = json!({"session_id": "alice-s1", "cwd": pa.dir, "prompt": "refactor the recon loop"});
2324 borg::presence::write(&a, &borg::presence::update(None, "work", "claude", "prompt", &ev, now, |_| ("alice@x".into(), "repo".into()))).unwrap();
2325
2326 let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
2327 let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
2328 let ticket = alice.ticket();
2329 let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
2330 served.unwrap();
2331 assert_eq!(synced.unwrap().presence, 1);
2332 let ds = borg::top::drones(&b, true, now);
2333 assert_eq!((ds.len(), ds[0].node.as_str(), ds[0].p.prompt.as_str()), (1, alice_key.as_str(), "refactor the recon loop"));
2334 let lessons = std::fs::read_to_string(b.join("lessons.jsonl")).unwrap_or_default();
2335 assert!(!lessons.contains("recon loop"), "never in lessons");
2336
2337 let to = format!("alice-s1@{alice_key}");
2338 let pre = json!({"session_id": "alice-s1", "cwd": pa.dir, "tool_name": "Bash"});
2339 let refused = |at: i64| borg::steer::around(&a, "pre_tool", &pre, Some(now), at, || None);
2340 let round = || async {
2341 let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
2342 synced.unwrap();
2343 served.unwrap()
2344 };
2345 borg::msg::send(&b, steering(&bob_key, Kind::Pause, &to, now, ""), now).unwrap();
2346 let served = round().await;
2347 assert_eq!((served.steered, served.dropped), (1, 0));
2348 let deny = refused(now + 1).expect("paused");
2349 let why = deny["hookSpecificOutput"]["permissionDecisionReason"].as_str().unwrap();
2350 assert!(why.contains(&format!("paused by bob@x (node {}, role steer)", &bob_key[..12])), "{why}");
2351
2352 let by_observer = serde_json::to_string(&steering(&observer.public(), Kind::Pause, ALL_TO, now, "")).unwrap();
2354 let h = lines::hash(&by_observer);
2355 let mut report = Report::default();
2356 let got = vec![Signed { origin: Origin::new(&observer, &h, now), line: by_observer.clone() }];
2357 alice.store_lines(&a.join(borg::msg::FILE), got, &BTreeSet::from([h]), &members(&a).unwrap(), None, &mut report).unwrap();
2358 assert_eq!((report.received, report.steered, report.dropped), (1, 0, 1));
2359 let log = std::fs::read_to_string(borg::steer::dropped_path(&a)).unwrap();
2360 assert!(log.contains("role observe may not steer"), "{log}");
2361
2362 borg::msg::send(&b, steering(&bob_key, Kind::Resume, &to, now + 2, ""), now + 2).unwrap();
2364 assert_eq!(round().await.steered, 1);
2365 assert_eq!(refused(now + 3), None);
2366
2367 borg::msg::send(&b, steering(&bob_key, Kind::Pause, &to, now + 4, ""), now + 4).unwrap();
2369 assert_eq!(round().await.steered, 1);
2370 assert!(refused(now + 5).is_some(), "paused again");
2371 let mut m = Membership::load(&a).unwrap().unwrap();
2372 m.revoke(&alice_id, &bob_key, now + 5).unwrap();
2373 m.save(&a).unwrap();
2374 alice.enforce().unwrap();
2375 assert!(borg::steer::read_admitted(&a).iter().all(|x| x.node != bob_key), "the revoked node's admissions are gone");
2376 assert_eq!(refused(now + 6), None, "and the pause no longer holds");
2377 alice.close().await;
2378 bob.close().await;
2379 }
2380
2381 #[tokio::test(flavor = "multi_thread")]
2385 async fn a_veto_on_one_node_reaches_the_drones_node_once() {
2386 let (pa, pb) = team("veto");
2387 let (a, b) = (home(&pa), home(&pb));
2388 let (alice_id, bob_id) = (Identity::load_or_create(&a).unwrap(), Identity::load_or_create(&b).unwrap());
2389 let mut m = Membership::load(&a).unwrap().unwrap();
2390 m.invite(&alice_id, &bob_id.public(), "steer").unwrap();
2391 m.save(&a).unwrap();
2392 let now = borg::learn::now_ms();
2393 let ev = json!({"session_id": "alice-s1", "cwd": pa.dir, "prompt": "look merges up"});
2394 borg::presence::write(&a, &borg::presence::update(None, "work", "claude", "prompt", &ev, now, |_| ("alice@x".into(), "repo".into()))).unwrap();
2395 write(&a, "lessons.jsonl", &[lesson("L1", 1, "how are merges found?")]);
2396 let asked = json!({"kind": "asked", "session": "alice-s1", "lessons": ["L1"], "ms": now - 10, "repo": "repo"}).to_string();
2397 write(&a, "outcomes.jsonl", &[asked]);
2398 let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
2399 let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
2400 let ticket = alice.ticket();
2401 let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
2402 let _ = (served.unwrap(), synced.unwrap());
2403
2404 let to = format!("alice-s1@{}", alice_id.public());
2405 let veto = steering(&bob_id.public(), borg::msg::Kind::Veto, &to, now, "never look merges up in the commit log");
2406 borg::msg::send(&b, veto, now).unwrap();
2407 assert!(!b.join("episodes.jsonl").exists(), "the issuing node records no veto of its own");
2408 for _ in 0..2 {
2409 let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
2410 let _ = (served.unwrap(), synced.unwrap());
2411 }
2412 let vetoed =
2413 |h: &Path| borg::outcome::read(&h.join("outcomes.jsonl")).into_iter().filter(|o| o.kind == borg::outcome::VETOED && o.lessons == ["L1"]).count();
2414 assert_eq!(vetoed(&a), 1, "credited once on the drone's node, over two rounds");
2415 let eps = std::fs::read_to_string(a.join("episodes.jsonl")).unwrap();
2416 assert_eq!(eps.lines().count(), 1);
2417 assert!(eps.contains("\"kind\":\"veto\"") && eps.contains("bob@x") && eps.contains("never look merges up"), "{eps}");
2418 let stats: Vec<_> = borg::stats::events(&a).into_iter().filter(|e| e.kind == borg::stats::VETO).collect();
2419 assert_eq!((stats.len(), stats[0].why.as_str()), (1, "mesh"));
2420 let caps = borg::harness::by_name("claude").unwrap().caps();
2422 let prompt = json!({"session_id": "alice-s1", "cwd": pa.dir});
2423 let out = borg::msg::deliver(&a, caps, "prompt", &prompt, Some(now - 10), now + 1, None).expect("shown");
2424 let ctx = out["hookSpecificOutput"]["additionalContext"].as_str().unwrap();
2425 assert!(ctx.contains(&format!("[borg] Veto from bob@x (node {}, role steer)", &bob_id.public()[..12])), "{ctx}");
2426 assert_eq!(borg::msg::deliver(&a, caps, "prompt", &prompt, Some(now - 10), now + 2, None), None, "once");
2427 let l1 = [borg::conscience::Challenge { id: "L1".into(), ..Default::default() }];
2429 let belief = |h: &Path| borg::memory::derive(&borg::outcome::read_team(&h.join("lessons.jsonl")), &l1, None)["L1"].vetoed;
2430 assert_eq!((belief(&a), belief(&b)), (1, 1));
2431 assert!(!b.join("outcomes.jsonl").exists() && !b.join("episodes.jsonl").exists(), "bob's node holds only the team copy");
2432 alice.close().await;
2433 bob.close().await;
2434 }
2435
2436 #[tokio::test(flavor = "multi_thread")]
2437 async fn a_local_collectives_presence_and_steering_never_leave() {
2438 let p = node("live-private", "local");
2439 let h = home(&p);
2440 let now = borg::learn::now_ms();
2441 let ev = json!({"session_id": "s1", "cwd": p.dir, "prompt": "private diary"});
2442 borg::presence::write(&h, &borg::presence::update(None, "work", "claude", "prompt", &ev, now, |_| ("me@x".into(), "r".into()))).unwrap();
2443 let me = Identity::load_or_create(&h).unwrap().public();
2444 borg::msg::send(&h, steering(&me, borg::msg::Kind::Pause, ALL_TO, now, "lunch"), now).unwrap();
2445 let held = borg::steer::read_admitted(&h);
2446 assert_eq!((held.len(), held[0].role), (1, borg::steer::Role::Local), "this machine's engineer steers it");
2447 assert!(Node::bind(&p, LOCAL.parse().unwrap()).await.is_err(), "no socket at all");
2448 }
2449
2450 #[test]
2453 fn the_window_paces_tells_and_not_steers() {
2454 use borg::msg::Kind;
2455 let node = "a".repeat(64);
2456 let to = format!("s1@{}", "b".repeat(64));
2457 let ms = [
2458 steering(&node, Kind::Pause, &to, 1000, ""),
2459 steering(&node, Kind::Resume, &to, 1001, ""),
2460 steering(&node, Kind::Tell, &to, 1002, "hi"),
2461 steering(&node, Kind::Tell, &to, 1003, "again"),
2462 ];
2463 let set: BTreeMap<Hash, String> = ms.iter().map(|m| serde_json::to_string(m).unwrap()).map(|l| (lines::hash(&l), l)).collect();
2464 let w = window(&set, 10);
2465 let kept: Vec<&str> = ms.iter().filter(|m| w.contains(&lines::hash(&serde_json::to_string(m).unwrap()))).map(|m| m.kind.as_str()).collect();
2466 assert_eq!(kept, ["pause", "resume", "tell"]);
2467 }
2468
2469 #[test]
2472 fn newer_tells_never_push_a_pause_out_of_the_window() {
2473 use borg::msg::Kind;
2474 let (node, per) = ("a".repeat(64), 5);
2475 let to = |i: usize| format!("s{i}@{}", "b".repeat(64));
2476 let ms: Vec<borg::msg::Message> = std::iter::once(steering(&node, Kind::Pause, &to(0), 1000, ""))
2477 .chain((0..=per).map(|i| steering(&node, Kind::Tell, &to(i), 1001 + i as i64, "hi")))
2478 .collect();
2479 let set: BTreeMap<Hash, String> = ms.iter().map(|m| serde_json::to_string(m).unwrap()).map(|l| (lines::hash(&l), l)).collect();
2480 let w = window(&set, per);
2481 let kept = |m: &borg::msg::Message| w.contains(&lines::hash(&serde_json::to_string(m).unwrap()));
2482 assert!(kept(&ms[0]), "the pause travels");
2483 assert_eq!((kept(&ms[1]), ms[2..].iter().filter(|m| kept(m)).count()), (false, per), "the oldest tell went");
2484 }
2485
2486 #[test]
2489 fn live_keeps_an_empty_directives_field_for_0_3_0() {
2490 let out = serde_json::to_value(Msg::Live { presence: Vec::new(), directives: Directives }).unwrap();
2491 assert_eq!(out, json!({"t": "live", "presence": [], "directives": []}));
2492 let theirs = json!({"t": "live", "presence": [], "directives": [{"kind": "pause", "sig": "00"}], "beats": []});
2493 assert!(matches!(serde_json::from_value::<Msg>(theirs).unwrap(), Msg::Live { .. }));
2494 assert!(matches!(serde_json::from_value::<Msg>(json!({"t": "live", "presence": []})).unwrap(), Msg::Live { .. }));
2495 }
2496
2497 fn answer(n: std::sync::Arc<Node>) {
2499 tokio::spawn(async move {
2500 while let Some(i) = n.incoming().await {
2501 let n = n.clone();
2502 tokio::spawn(async move {
2503 let _ = n.handle(i).await;
2504 });
2505 }
2506 });
2507 }
2508
2509 fn ticket(admin: &Node, role: &str, ttl_ms: i64, uses: u32) -> Ticket {
2510 let home = admin.home();
2511 let m = Membership::load(home).unwrap().unwrap();
2512 let invite = invite::issue(home, &admin.me, &m, role, ttl_ms, uses, borg::learn::now_ms()).unwrap();
2513 Ticket::decode(&Ticket { invite, relay: admin.relay.clone(), hints: admin.hints() }.encode()).unwrap()
2514 }
2515
2516 #[tokio::test(flavor = "multi_thread")]
2520 async fn three_nodes_join_by_ticket_and_sync_a_lesson() {
2521 let pa = crew("ticket", &["alice"]).remove(0);
2522 let (pb, pc) = (node("ticket-bob", "team"), node("ticket-carol", "team"));
2523 let l1 = lesson("L1", 1, "who owns the lock?");
2524 write(&home(&pa), "lessons.jsonl", std::slice::from_ref(&l1));
2525 let alice = std::sync::Arc::new(Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap());
2526 answer(alice.clone());
2527
2528 let one = ticket(&alice, "member", 60_000, 1);
2529 let bob = Node::joining(&pb, LOCAL.parse().unwrap()).await.unwrap().quiet();
2530 let (m, rep) = bob.join(&one).await.unwrap();
2531 assert_eq!(m.role_of(&bob.public()), Some("member"));
2532 assert_eq!(rep.received, 1, "bob syncs at once");
2533 assert!(holds(&home(&pb), "lessons.jsonl", &l1));
2534 assert_eq!(peers::load(&home(&pb)).get(&alice.public()), Some(&addr_of(&alice)), "bob knows where alice is");
2535 assert!(peers::load(&home(&pa)).contains_key(&bob.public()), "alice knows bob, by key at least");
2536
2537 let carol = Node::joining(&pc, LOCAL.parse().unwrap()).await.unwrap().quiet();
2538 let err = carol.join(&one).await.unwrap_err();
2539 assert!(err.contains("already used"), "{err}");
2540 assert!(Membership::load(&home(&pc)).unwrap().is_none() && !home(&pc).join("lessons.jsonl").exists(), "carol got nothing");
2541 assert!(bob.join(&one).await.is_ok(), "bob again (a lost answer): welcomed");
2542
2543 let gone = ticket(&alice, "member", 1, 1);
2544 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2545 assert!(carol.join(&gone).await.unwrap_err().contains("expired"));
2546
2547 let two = ticket(&alice, "observe", 60_000, 1);
2548 let (m, rep) = carol.join(&two).await.unwrap();
2549 assert_eq!(m.role_of(&carol.public()), Some("observe"));
2550 assert_eq!(rep.received, 1);
2551 let held = Membership::load(&home(&pa)).unwrap().unwrap();
2552 assert_eq!((held.role_of(&bob.public()), held.role_of(&carol.public())), (Some("member"), Some("observe")));
2553 assert!(invite::pending(alice.home(), borg::learn::now_ms()).is_empty(), "both tickets spent");
2554
2555 assert_eq!(Membership::load(&home(&pb)).unwrap().unwrap().role_of(&carol.public()), None);
2557 bob.sync(&format!("{}@{}", alice.public(), addr_of(&alice))).await.unwrap();
2558 assert_eq!(Membership::load(&home(&pb)).unwrap().unwrap().role_of(&carol.public()), Some("observe"));
2559 for n in [bob, carol] {
2560 n.close().await;
2561 }
2562 alice.close().await;
2563 }
2564
2565 #[tokio::test(flavor = "multi_thread")]
2569 async fn join_and_sync_go_through_the_relay_when_no_direct_address_is_known() {
2570 let (_map, url, _server) = iroh::test_utils::run_relay_server().await.unwrap();
2571 let pa = crew("relay", &["alice"]).remove(0);
2572 let pb = node("relay-bob", "team");
2573 for p in [&pa, &pb] {
2574 let cfg = json!({"default": "off", "collectives": [{"name": "work", "paths": [p.dir], "share": "team", "relay": url.to_string()}]});
2575 std::fs::write(&p.config, cfg.to_string()).unwrap();
2576 }
2577 let l1 = lesson("L1", 1, "does this cross the NAT?");
2578 write(&home(&pa), "lessons.jsonl", std::slice::from_ref(&l1));
2579 let trust = |b: Builder| b.ca_tls_config(iroh::tls::CaTlsConfig::insecure_skip_verify());
2580 let alice = std::sync::Arc::new(Node::open(&pa, None, true, trust).await.unwrap());
2581 let bob = Node::open(&pb, None, false, trust).await.unwrap().quiet();
2582 assert!(matches!(alice.relay(), Relay::Custom(_)));
2583 assert!(alice.tickets().is_empty() && bob.tickets().is_empty(), "no direct address on either side");
2584 let wait = std::time::Duration::from_secs(10);
2585 assert!(alice.online(wait).await && bob.online(wait).await, "both reach the relay");
2586 answer(alice.clone());
2587
2588 let mut t = ticket(&alice, "member", 60_000, 1);
2589 t.hints.retain(|h| !matches!(h, Hint::Direct(_)));
2590 let (m, rep) = bob.join(&t).await.unwrap();
2591 assert_eq!(m.role_of(&bob.public()), Some("member"));
2592 assert_eq!(rep.received, 1);
2593 assert!(holds(&home(&pb), "lessons.jsonl", &l1), "the lesson crossed through the relay");
2594 let l2 = lesson("L2", 1, "and back?");
2596 append_lines(&home(&pb), "lessons.jsonl", std::slice::from_ref(&l2));
2597 assert_eq!(bob.sync(&alice.public()).await.unwrap().sent, 1);
2598 assert!(holds(&home(&pa), "lessons.jsonl", &l2));
2599 bob.close().await;
2600 alice.close().await;
2601 }
2602}