majordomus_cli/mesh/state.rs
1//! The cooperation state: what the journal's events mean, folded. Pure: the same set of
2//! events and the same liveness verdicts yield the same state on every runtime, whatever
3//! order the events arrived in and however often — events are applied in `(lamport,
4//! stream, seq)` order, and a set has no duplicates. That is the whole convergence
5//! argument, and a property test holds it.
6//!
7//! Exclusivity is decided here, and decided the same way everywhere:
8//!
9//! - A claim **holds** while it is not released, its session is not closed, and its
10//! stream is alive (its beat rose within the expiry on this runtime's clock). A dead
11//! runtime's claims therefore end on their own; nobody releases them for it.
12//! - Two live **exclusive** claims of different sessions whose scopes meet are a
13//! **conflict**. The claim with the lower `(lamport, stream, seq)` of its acquisition
14//! wins; the other is `conflicted` and names the winner. Admission refuses such a claim
15//! up front, so a conflict can only arise from genuinely concurrent claims — two sides
16//! of a partition, or two runtimes in the same round — and then every runtime names the
17//! same winner. Never "last packet wins".
18//! - Scopes meet by [`crate::peers::claims_meet`], the peer board's own predicate: one
19//! semantics for a claim on this machine and on another.
20//!
21//! ```
22//! use majordomus_cli::mesh::journal::{ClaimMode, EventBody, Journal, StreamLiveness};
23//! use majordomus_cli::mesh::identity::NodeIdentity;
24//! use majordomus_cli::mesh::state::{fold, ClaimState};
25//! use std::sync::Arc;
26//!
27//! let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
28//! "repo".into(), None).unwrap();
29//! j.append_own(EventBody::ClaimAcquired { claim: "c1".into(), session: "s1".into(),
30//! scope: vec!["apps".into()], intent: None, mode: ClaimMode::Exclusive, issue: None }).unwrap();
31//! let state = fold(&j.events(), &|_| StreamLiveness::Own);
32//! assert_eq!(state.claims[0].state, ClaimState::Held);
33//! ```
34
35use std::collections::BTreeMap;
36
37use schemars::JsonSchema;
38use serde::{Deserialize, Serialize};
39
40use super::journal::{
41 canonical_json, causal_key, ClaimMode, EventBody, HandoverBody, MeshEvent, SessionInfo,
42 StreamId, StreamLiveness,
43};
44
45/// A session's standing, as every runtime of the mesh reads it. The distinction that
46/// matters is between `Closed` and `Expired`: the first is a session that said it was
47/// finished, the second one whose runtime stopped beating and can no longer say anything.
48/// Both end the session's claims, but only the second is a machine nobody should expect an
49/// answer from.
50///
51/// ```
52/// use std::sync::Arc;
53/// use majordomus_cli::mesh::identity::NodeIdentity;
54/// use majordomus_cli::mesh::journal::{EventBody, Journal, SessionInfo, StreamLiveness};
55/// use majordomus_cli::mesh::state::{fold, SessionState};
56///
57/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
58/// "repo".into(), None).unwrap();
59/// j.append_own(EventBody::SessionOpened { info: SessionInfo::named("s1", "cli") }).unwrap();
60/// assert_eq!(fold(&j.events(), &|_| StreamLiveness::Own).sessions[0].state, SessionState::Active);
61///
62/// // the same events, read on a runtime that no longer hears the writer
63/// let gone = fold(&j.events(), &|_| StreamLiveness::Expired);
64/// assert_eq!(gone.sessions[0].state, SessionState::Expired);
65/// ```
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
67#[serde(rename_all = "snake_case")]
68pub enum SessionState {
69 /// Open, and its runtime is alive.
70 Active,
71 /// Closed by its own runtime.
72 Closed,
73 /// Never closed, but its runtime stopped beating: gone, as far as anyone can tell.
74 Expired,
75}
76
77/// One session anywhere in the mesh, as the fold sees it: what the session said about
78/// itself, where it is writing from, and whether it still counts. The key is
79/// `<stream>/<session>` rather than the session's own id, because two runtimes may both
80/// call a session `s1` and the mesh has to hold them apart.
81///
82/// ```
83/// use std::sync::Arc;
84/// use majordomus_cli::mesh::identity::NodeIdentity;
85/// use majordomus_cli::mesh::journal::{EventBody, Journal, SessionInfo, StreamLiveness};
86/// use majordomus_cli::mesh::state::{fold, SessionView};
87///
88/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
89/// "repo".into(), None).unwrap();
90/// j.append_own(EventBody::SessionOpened { info: SessionInfo::named("s1", "claude-code") })
91/// .unwrap();
92///
93/// let state = fold(&j.events(), &|_| StreamLiveness::Own);
94/// let view: &SessionView = &state.sessions[0];
95/// assert!(view.key.ends_with("/s1"), "qualified by the stream that wrote it: {}", view.key);
96/// assert_eq!(view.info.client, "claude-code");
97/// assert_eq!(view.runtime, view.stream.runtime_key());
98/// ```
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
100pub struct SessionView {
101 /// `<stream>/<session>`: unique across the mesh.
102 pub key: String,
103 /// The writing stream.
104 pub stream: StreamId,
105 /// `<node>-<runtime>`.
106 pub runtime: String,
107 /// The node (machine).
108 pub node: String,
109 /// What the session last said about itself.
110 pub info: SessionInfo,
111 /// Its standing.
112 pub state: SessionState,
113 /// The Lamport stamp of its latest word.
114 pub updated_lamport: u64,
115}
116
117/// A claim's standing, and — where it ended or lost — what ended it. The two variants that
118/// carry a string exist because the bare verdict is not actionable: a worker told its claim
119/// expired needs to know whether its session closed or its runtime stopped beating, and one
120/// told its claim is conflicted needs the key of the claim that beat it, which is the same
121/// key on every runtime.
122///
123/// ```
124/// use majordomus_cli::mesh::state::ClaimState;
125///
126/// assert!(ClaimState::Held.is_live());
127/// assert!(ClaimState::Conflicted("s/c1".into()).is_live(), "still held, and not the winner");
128/// assert!(!ClaimState::Released.is_live());
129///
130/// // the detail travels with the verdict, so a reader is never left asking who won
131/// let value = serde_json::to_value(ClaimState::Conflicted("s/c1".into())).unwrap();
132/// assert_eq!(value["state"], "conflicted");
133/// assert_eq!(value["detail"], "s/c1");
134/// ```
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
136#[serde(rename_all = "snake_case", tag = "state", content = "detail")]
137pub enum ClaimState {
138 /// Live; for an exclusive claim, the winner of its scope.
139 Held,
140 /// Released by its holder.
141 Released,
142 /// Ended without a release: the holder's session closed or its runtime expired.
143 Expired(String),
144 /// Live, exclusive, and beaten to its scope by the claim whose key this names.
145 Conflicted(String),
146}
147
148impl ClaimState {
149 /// Whether the claim is live (held or conflicted). A conflicted claim counts as live
150 /// because it has not gone away: its holder is still working, and the next claim over
151 /// that scope has to meet it too. Only a release, a closed session or an expired
152 /// runtime ends a claim.
153 ///
154 /// ```
155 /// use majordomus_cli::mesh::state::ClaimState;
156 ///
157 /// assert!(ClaimState::Held.is_live());
158 /// assert!(ClaimState::Conflicted("s/c1".into()).is_live(), "losing is not ending");
159 /// assert!(!ClaimState::Released.is_live());
160 /// assert!(!ClaimState::Expired("its runtime stopped beating".into()).is_live());
161 /// ```
162 pub fn is_live(&self) -> bool {
163 matches!(self, ClaimState::Held | ClaimState::Conflicted(_))
164 }
165}
166
167/// One claim anywhere in the mesh: who took what, for what, and where it stands. The
168/// acquisition's Lamport stamp and sequence are carried rather than derived on demand,
169/// because they are what exclusivity is decided by — every runtime ranks two competing
170/// claims by the same triple and so names the same winner, with no message passing and no
171/// "last packet wins".
172///
173/// ```
174/// use std::sync::Arc;
175/// use majordomus_cli::mesh::identity::NodeIdentity;
176/// use majordomus_cli::mesh::journal::{ClaimMode, EventBody, Journal, StreamLiveness};
177/// use majordomus_cli::mesh::state::{fold, ClaimState, ClaimView};
178///
179/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
180/// "repo".into(), None).unwrap();
181/// j.append_own(EventBody::ClaimAcquired { claim: "c1".into(), session: "s1".into(),
182/// scope: vec!["apps/majordomus-cli".into()], intent: Some("document the mesh".into()),
183/// mode: ClaimMode::Exclusive, issue: Some("#184".into()) }).unwrap();
184///
185/// let state = fold(&j.events(), &|_| StreamLiveness::Own);
186/// let claim: &ClaimView = &state.claims[0];
187/// assert_eq!(claim.scope, ["apps/majordomus-cli"]);
188/// assert_eq!(claim.state, ClaimState::Held);
189/// assert!(claim.acquired_lamport > 0, "the order exclusivity is decided in");
190/// ```
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
192pub struct ClaimView {
193 /// `<stream>/<claim>`.
194 pub key: String,
195 /// The writing stream.
196 pub stream: StreamId,
197 /// `<node>-<runtime>`.
198 pub runtime: String,
199 /// The holding session's key.
200 pub session: String,
201 /// The claimed paths.
202 pub scope: Vec<String>,
203 /// What it is for.
204 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub intent: Option<String>,
206 /// Exclusive or advisory.
207 pub mode: ClaimMode,
208 /// The issue it is for.
209 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub issue: Option<String>,
211 /// The Lamport stamp of the acquisition.
212 pub acquired_lamport: u64,
213 /// The sequence of the acquisition within its stream: with the stamp and the stream,
214 /// the total order in which exclusivity is decided.
215 pub acquired_seq: u64,
216 /// Its standing.
217 pub state: ClaimState,
218}
219
220impl ClaimView {
221 /// Where the claim stands in the one order exclusivity is decided by: the Lamport stamp
222 /// of its acquisition, then its stream, then its sequence. Owned, because a collection
223 /// keys a map by it rather than sorting itself with a comparator of its own.
224 fn order_key(&self) -> (u64, StreamId, u64) {
225 (
226 self.acquired_lamport,
227 self.stream.clone(),
228 self.acquired_seq,
229 )
230 }
231}
232
233/// Two live claims of different sessions whose scopes meet, at least one advisory:
234/// reported, never refused.
235///
236/// An overlap is information, not a verdict — it is how two workers find out they are in
237/// the same place without either being stopped. The meeting paths are listed pairwise
238/// rather than summarized, because the useful question is which path of mine meets which
239/// path of theirs.
240///
241/// ```
242/// use std::sync::Arc;
243/// use majordomus_cli::mesh::identity::NodeIdentity;
244/// use majordomus_cli::mesh::journal::{ClaimMode, EventBody, Journal, StreamLiveness};
245/// use majordomus_cli::mesh::state::{fold, ClaimOverlap};
246///
247/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
248/// "repo".into(), None).unwrap();
249/// for (claim, session, mode) in [
250/// ("c1", "s1", ClaimMode::Exclusive),
251/// ("c2", "s2", ClaimMode::Advisory),
252/// ] {
253/// j.append_own(EventBody::ClaimAcquired { claim: claim.into(), session: session.into(),
254/// scope: vec!["apps".into()], intent: None, mode, issue: None }).unwrap();
255/// }
256///
257/// let state = fold(&j.events(), &|_| StreamLiveness::Own);
258/// let overlap: &ClaimOverlap = &state.overlaps[0];
259/// assert_ne!(overlap.first, overlap.second, "two claims, two sessions");
260/// assert_eq!(overlap.paths, [("apps".to_string(), "apps".to_string())]);
261/// ```
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
263pub struct ClaimOverlap {
264 /// The earlier claim's key.
265 pub first: String,
266 /// The later claim's key.
267 pub second: String,
268 /// The paths that meet, as `first ↔ second` pairs.
269 pub paths: Vec<(String, String)>,
270}
271
272/// A handover anywhere in the mesh, with everyone who has taken it. The id is the digest
273/// of the body, so the same handover published twice — by a retry, or by two runtimes that
274/// both saw it — is one handover here; and `consumed_by` is what keeps two workers from
275/// silently picking up the same continuity, since the list is folded from events every
276/// runtime holds.
277///
278/// ```
279/// use std::sync::Arc;
280/// use majordomus_cli::mesh::identity::NodeIdentity;
281/// use majordomus_cli::mesh::journal::{EventBody, HandoverBody, Journal, StreamLiveness};
282/// use majordomus_cli::mesh::state::{fold, HandoverView};
283///
284/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
285/// "repo".into(), None).unwrap();
286/// let body = "# Objective\nship\n".to_string();
287/// let handover = HandoverBody { id: HandoverBody::digest_of(&body), task: None, issue: None,
288/// milestone: None, branch: Some("feature/x".into()), head: None, created_at: None,
289/// name: None, body };
290/// let id = handover.id.clone();
291/// j.append_own(EventBody::HandoverPublished { handover }).unwrap();
292/// j.append_own(EventBody::HandoverConsumed { handover: id.clone(), session: "s1".into() })
293/// .unwrap();
294///
295/// let state = fold(&j.events(), &|_| StreamLiveness::Own);
296/// let view: &HandoverView = &state.handovers[0];
297/// assert_eq!(view.id, id, "identified by what it says, not by who said it");
298/// assert_eq!(view.consumed_by.len(), 1, "somebody has picked this one up");
299/// ```
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
301pub struct HandoverView {
302 /// The content digest.
303 pub id: String,
304 /// The stream that first published it.
305 pub stream: StreamId,
306 /// `<node>-<runtime>` of the publisher.
307 pub runtime: String,
308 /// The Lamport stamp of the first publication.
309 pub published_lamport: u64,
310 /// The record's facts and body.
311 pub handover: HandoverBody,
312 /// Every session that consumed it, by key, in Lamport order.
313 pub consumed_by: Vec<String>,
314}
315
316/// A review request's standing: whether anybody has answered it yet. There is deliberately
317/// no `Approved` or `Rejected` here — a request may be answered by several runtimes and the
318/// verdicts are the answers' own, so collapsing them into one state would throw away the
319/// disagreement that makes a second reviewer worth asking.
320///
321/// ```
322/// use std::sync::Arc;
323/// use majordomus_cli::mesh::identity::NodeIdentity;
324/// use majordomus_cli::mesh::journal::{EventBody, Journal, StreamLiveness};
325/// use majordomus_cli::mesh::state::{fold, ReviewState};
326///
327/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
328/// "repo".into(), None).unwrap();
329/// j.append_own(EventBody::ReviewRequested { review: "r1".into(), session: "s1".into(),
330/// subject: "feature/x".into(), scope: vec![], issue: None, reviewer: None, body: None }).unwrap();
331/// let asked = fold(&j.events(), &|_| StreamLiveness::Own);
332/// assert_eq!(asked.reviews[0].state, ReviewState::Open);
333///
334/// let request = asked.reviews[0].key.clone();
335/// j.append_own(EventBody::ReviewAnswered { request, session: "s2".into(),
336/// verdict: "approved".into(), note: None }).unwrap();
337/// assert_eq!(fold(&j.events(), &|_| StreamLiveness::Own).reviews[0].state, ReviewState::Answered);
338/// ```
339#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
340#[serde(rename_all = "snake_case")]
341pub enum ReviewState {
342 /// Nobody has answered.
343 Open,
344 /// At least one answer.
345 Answered,
346 /// The runtime that asked ended the request: done, void or superseded. Whatever was
347 /// answered before stays with it.
348 Withdrawn,
349 /// Nobody answered and the runtime that asked stopped beating. A reading of liveness,
350 /// like a claim's expiry, decided from the journal and the streams' beats so that no
351 /// runtime needs another's clock — a runtime that beats again reads open again.
352 Expired,
353}
354
355impl ReviewState {
356 /// Whether the request still waits for a verdict: open, and nothing else.
357 ///
358 /// ```
359 /// use majordomus_cli::mesh::state::ReviewState;
360 /// assert!(ReviewState::Open.is_waiting());
361 /// assert!(!ReviewState::Answered.is_waiting() && !ReviewState::Withdrawn.is_waiting());
362 /// assert!(!ReviewState::Expired.is_waiting());
363 /// ```
364 pub fn is_waiting(self) -> bool {
365 self == ReviewState::Open
366 }
367
368 /// The state's word, as every surface prints it.
369 ///
370 /// ```
371 /// use majordomus_cli::mesh::state::ReviewState;
372 /// assert_eq!(ReviewState::Withdrawn.as_str(), "withdrawn");
373 /// assert_eq!(serde_json::to_value(ReviewState::Expired).unwrap(), ReviewState::Expired.as_str());
374 /// ```
375 pub fn as_str(self) -> &'static str {
376 match self {
377 ReviewState::Open => "open",
378 ReviewState::Answered => "answered",
379 ReviewState::Withdrawn => "withdrawn",
380 ReviewState::Expired => "expired",
381 }
382 }
383}
384
385/// One answer to a review request: who answered, what they decided, and why. The verdict
386/// is one of the three declared words, so a requester can act on it without reading the
387/// note; the note is where the reasoning goes and is optional, because "approved" often
388/// needs none.
389///
390/// ```
391/// use majordomus_cli::mesh::state::ReviewAnswer;
392///
393/// let answer = ReviewAnswer {
394/// session: "a1b2c3d4-r1-0f/s2".into(),
395/// verdict: "changes_requested".into(),
396/// note: Some("the fold is not deterministic".into()),
397/// };
398/// assert_eq!(answer.verdict, "changes_requested");
399///
400/// // the note is omitted rather than carried empty when there is nothing to say
401/// let bare = ReviewAnswer { note: None, ..answer };
402/// let value = serde_json::to_value(&bare).unwrap();
403/// assert!(value.get("note").is_none());
404/// ```
405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
406pub struct ReviewAnswer {
407 /// The answering session's key.
408 pub session: String,
409 /// The verdict.
410 pub verdict: String,
411 /// The note.
412 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub note: Option<String>,
414}
415
416/// One review request anywhere in the mesh, with every answer it has drawn. A request with
417/// no `reviewer` is an offer to the whole mesh rather than to nobody, which is why the
418/// answers are a list: any linked runtime carrying the `reviews` feature may add one, and
419/// they are kept in Lamport order so that every runtime reads them in the same sequence.
420///
421/// ```
422/// use std::sync::Arc;
423/// use majordomus_cli::mesh::identity::NodeIdentity;
424/// use majordomus_cli::mesh::journal::{EventBody, Journal, StreamLiveness};
425/// use majordomus_cli::mesh::state::{fold, ReviewView};
426///
427/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
428/// "repo".into(), None).unwrap();
429/// j.append_own(EventBody::ReviewRequested { review: "r1".into(), session: "s1".into(),
430/// subject: "feature/mesh-cooperation".into(), scope: vec!["src/mesh".into()],
431/// issue: Some("#184".into()), reviewer: None, body: None }).unwrap();
432/// let request = fold(&j.events(), &|_| StreamLiveness::Own).reviews[0].key.clone();
433/// j.append_own(EventBody::ReviewAnswered { request, session: "s2".into(),
434/// verdict: "approved".into(), note: Some("reads well".into()) }).unwrap();
435///
436/// let state = fold(&j.events(), &|_| StreamLiveness::Own);
437/// let review: &ReviewView = &state.reviews[0];
438/// assert_eq!(review.subject, "feature/mesh-cooperation");
439/// assert_eq!(review.reviewer, None, "offered to the mesh, not addressed");
440/// assert_eq!(review.answers[0].verdict, "approved");
441/// ```
442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
443pub struct ReviewView {
444 /// `<stream>/<review>`.
445 pub key: String,
446 /// The requesting session's key.
447 pub session: String,
448 /// What is to be reviewed.
449 pub subject: String,
450 /// Paths it covers.
451 pub scope: Vec<String>,
452 /// The issue.
453 #[serde(default, skip_serializing_if = "Option::is_none")]
454 pub issue: Option<String>,
455 /// The runtime asked, when one was.
456 #[serde(default, skip_serializing_if = "Option::is_none")]
457 pub reviewer: Option<String>,
458 /// What the reviewer needs beyond the subject, when the requester wrote it.
459 #[serde(default, skip_serializing_if = "Option::is_none")]
460 pub body: Option<String>,
461 /// Its standing.
462 pub state: ReviewState,
463 /// The answers, in Lamport order.
464 pub answers: Vec<ReviewAnswer>,
465 /// The requester's own result or reason, written when it withdrew the request. Never an
466 /// answer: a review is another session's verdict.
467 #[serde(default, skip_serializing_if = "Option::is_none")]
468 pub result: Option<String>,
469}
470
471/// The whole folded state, every collection in key order, and its digest: two runtimes
472/// that agree print the same digest.
473///
474/// The digest is the point. Convergence is otherwise a claim nobody can check from one
475/// machine; with it, two operators compare one short string and know whether their
476/// runtimes are looking at the same world. `opaque_events` is the other half of that
477/// honesty: events of kinds this executable does not interpret are counted and relayed
478/// rather than dropped, so a newer peer's traffic is visible instead of silently missing.
479///
480/// ```
481/// use std::sync::Arc;
482/// use majordomus_cli::mesh::identity::NodeIdentity;
483/// use majordomus_cli::mesh::journal::{EventBody, Journal, SessionInfo, StreamLiveness};
484/// use majordomus_cli::mesh::state::{fold, CooperationState};
485///
486/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
487/// "repo".into(), None).unwrap();
488/// j.append_own(EventBody::SessionOpened { info: SessionInfo::named("s1", "cli") }).unwrap();
489///
490/// let events = j.events();
491/// let state: CooperationState = fold(&events, &|_| StreamLiveness::Own);
492/// assert_eq!(state.digest.len(), 32);
493///
494/// // the same events in another order, and twice over, are the same state
495/// let mut shuffled = events.clone();
496/// shuffled.reverse();
497/// shuffled.extend(events);
498/// assert_eq!(fold(&shuffled, &|_| StreamLiveness::Own).digest, state.digest);
499/// ```
500#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
501pub struct CooperationState {
502 /// Every session.
503 pub sessions: Vec<SessionView>,
504 /// Every claim.
505 pub claims: Vec<ClaimView>,
506 /// Advisory overlaps between live claims.
507 pub overlaps: Vec<ClaimOverlap>,
508 /// Every handover.
509 pub handovers: Vec<HandoverView>,
510 /// Every review request.
511 pub reviews: Vec<ReviewView>,
512 /// Events of kinds this executable does not interpret.
513 pub opaque_events: usize,
514 /// 32 hex of the SHA-256 of the canonical JSON of everything above.
515 pub digest: String,
516}
517
518/// Fold events into state. `liveness` answers for each stream; the caller supplies the
519/// journal's verdicts, a test supplies whatever it is testing.
520///
521/// Liveness is a parameter rather than something read here because it is the one input
522/// that is not in the events: whether a stream still beats is a local observation, and
523/// making it an argument is what keeps the fold itself pure — the same events with the
524/// same verdicts give the same state, in any order and however often they are repeated.
525///
526/// ```
527/// use std::sync::Arc;
528/// use majordomus_cli::mesh::identity::NodeIdentity;
529/// use majordomus_cli::mesh::journal::{ClaimMode, EventBody, Journal, StreamLiveness};
530/// use majordomus_cli::mesh::state::{fold, ClaimState};
531///
532/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
533/// "repo".into(), None).unwrap();
534/// j.append_own(EventBody::ClaimAcquired { claim: "c1".into(), session: "s1".into(),
535/// scope: vec!["apps".into()], intent: None, mode: ClaimMode::Exclusive, issue: None })
536/// .unwrap();
537///
538/// // read where the writer is alive, the claim holds
539/// let here = fold(&j.events(), &|_| StreamLiveness::Own);
540/// assert_eq!(here.claims[0].state, ClaimState::Held);
541///
542/// // read where its beat has stopped, it ended on its own; nobody released it
543/// let elsewhere = fold(&j.events(), &|_| StreamLiveness::Expired);
544/// assert!(matches!(elsewhere.claims[0].state, ClaimState::Expired(_)));
545/// ```
546pub fn fold(
547 events: &[MeshEvent],
548 liveness: &dyn Fn(&StreamId) -> StreamLiveness,
549) -> CooperationState {
550 // Keyed by the causal position, so the map both orders and deduplicates: an event is
551 // identified by `(stream, seq)`, and the same event twice is one entry.
552 let ordered: Vec<&MeshEvent> = events
553 .iter()
554 .map(|e| (causal_key(e), e))
555 .collect::<BTreeMap<_, _>>()
556 .into_values()
557 .collect();
558
559 let mut sessions: BTreeMap<String, SessionView> = BTreeMap::new();
560 let mut claims: BTreeMap<String, ClaimView> = BTreeMap::new();
561 let mut handovers: BTreeMap<String, HandoverView> = BTreeMap::new();
562 let mut reviews: BTreeMap<String, ReviewView> = BTreeMap::new();
563 // Where and when each request was asked: what its expiry is judged by.
564 let mut asked_at: BTreeMap<String, StreamId> = BTreeMap::new();
565 let mut opaque = 0usize;
566 let mut closed_at: BTreeMap<String, u64> = BTreeMap::new();
567
568 for event in ordered {
569 let Some(body) = event.body() else {
570 opaque += 1;
571 continue;
572 };
573 let stream = &event.stream;
574 let key = |local: &str| format!("{stream}/{local}");
575 match body {
576 EventBody::SessionOpened { info } => {
577 let k = key(&info.session);
578 sessions.insert(
579 k.clone(),
580 SessionView {
581 key: k,
582 stream: stream.clone(),
583 runtime: stream.runtime_key(),
584 node: stream.node().to_string(),
585 info,
586 state: SessionState::Active,
587 updated_lamport: event.lamport,
588 },
589 );
590 }
591 EventBody::SessionClosed { session } => {
592 let k = key(&session);
593 // A close ends every claim acquired before it, even if the same session id
594 // opens again later (a board position is reused): those claims stay ended.
595 let closed = closed_at.entry(k.clone()).or_insert(0);
596 *closed = (*closed).max(event.lamport);
597 if let Some(view) = sessions.get_mut(&k) {
598 view.state = SessionState::Closed;
599 view.updated_lamport = event.lamport;
600 }
601 }
602 EventBody::ClaimAcquired {
603 claim,
604 session,
605 scope,
606 intent,
607 mode,
608 issue,
609 } => {
610 let k = key(&claim);
611 claims.insert(
612 k.clone(),
613 ClaimView {
614 key: k,
615 stream: stream.clone(),
616 runtime: stream.runtime_key(),
617 session: key(&session),
618 scope,
619 intent,
620 mode,
621 issue,
622 acquired_lamport: event.lamport,
623 acquired_seq: event.seq,
624 state: ClaimState::Held,
625 },
626 );
627 }
628 EventBody::ClaimReleased { claim } => {
629 if let Some(view) = claims.get_mut(&key(&claim)) {
630 view.state = ClaimState::Released;
631 }
632 }
633 EventBody::HandoverPublished { handover } => {
634 handovers
635 .entry(handover.id.clone())
636 .or_insert_with(|| HandoverView {
637 id: handover.id.clone(),
638 stream: stream.clone(),
639 runtime: stream.runtime_key(),
640 published_lamport: event.lamport,
641 handover,
642 consumed_by: Vec::new(),
643 });
644 }
645 EventBody::HandoverConsumed { handover, session } => {
646 if let Some(view) = handovers.get_mut(&handover) {
647 let consumer = key(&session);
648 if !view.consumed_by.contains(&consumer) {
649 view.consumed_by.push(consumer);
650 }
651 }
652 }
653 EventBody::ReviewRequested {
654 review,
655 session,
656 subject,
657 scope,
658 issue,
659 reviewer,
660 body,
661 } => {
662 let k = key(&review);
663 asked_at.entry(k.clone()).or_insert(event.stream.clone());
664 reviews.entry(k.clone()).or_insert(ReviewView {
665 key: k,
666 session: key(&session),
667 subject,
668 scope,
669 issue,
670 reviewer,
671 body,
672 state: ReviewState::Open,
673 answers: Vec::new(),
674 result: None,
675 });
676 }
677 EventBody::ReviewAnswered {
678 request,
679 session,
680 verdict,
681 note,
682 } => {
683 // A review is an independent verdict: the session that asked for it cannot
684 // answer it. Admission refuses that; a peer that writes one anyway is ignored.
685 if let Some(view) = reviews
686 .get_mut(&request)
687 .filter(|view| view.session != key(&session))
688 {
689 if view.state != ReviewState::Withdrawn {
690 view.state = ReviewState::Answered;
691 }
692 view.answers.push(ReviewAnswer {
693 session: key(&session),
694 verdict,
695 note,
696 });
697 }
698 }
699 EventBody::ReviewWithdrawn {
700 request,
701 session: _,
702 note,
703 } => {
704 // Only the runtime that asked ends its request — any of its sessions, because
705 // an MCP session is one connection and a reconnected asker is another
706 // session of the same runtime. A withdrawal another runtime wrote is
707 // ignored, as admission refuses it. The withdrawing session is recorded in
708 // the event and not needed here.
709 if let Some(view) = reviews
710 .get_mut(&request)
711 .filter(|view| view.key.starts_with(&format!("{stream}/")))
712 {
713 view.state = ReviewState::Withdrawn;
714 view.result = note;
715 }
716 }
717 }
718 }
719
720 // A request nobody answered ends with the runtime that asked: when its stream stops
721 // beating. Not when the asking session closes — an MCP session is one connection, and a
722 // lane request outlives many of them. This is a reading of liveness, like a claim's: a
723 // runtime that sleeps and beats again reads open again.
724 for view in reviews.values_mut() {
725 if view.state != ReviewState::Open {
726 continue;
727 }
728 if let Some(stream) = asked_at.get(&view.key) {
729 if !liveness(stream).is_alive() {
730 view.state = ReviewState::Expired;
731 }
732 }
733 }
734
735 // StreamLiveness: a session or claim of a dead stream has ended, whatever it last said.
736 for view in sessions.values_mut() {
737 if view.state == SessionState::Active && !liveness(&view.stream).is_alive() {
738 view.state = SessionState::Expired;
739 }
740 }
741 for view in claims.values_mut() {
742 if view.state != ClaimState::Held {
743 continue;
744 }
745 if !liveness(&view.stream).is_alive() {
746 view.state = ClaimState::Expired("its runtime stopped beating".into());
747 } else if closed_at
748 .get(&view.session)
749 .is_some_and(|closed| view.acquired_lamport < *closed)
750 {
751 view.state = ClaimState::Expired("its session closed".into());
752 } else if let Some(session) = sessions.get(&view.session) {
753 if session.state != SessionState::Active {
754 view.state = ClaimState::Expired("its session closed".into());
755 }
756 }
757 }
758
759 // Exclusivity: live exclusive claims in acquisition order; the first to a scope wins.
760 // The map is what orders them — the same `(lamport, stream, seq)` on every runtime, so
761 // every runtime names the same winner.
762 let exclusive: Vec<String> = claims
763 .values()
764 .filter(|c| c.state.is_live() && c.mode == ClaimMode::Exclusive)
765 .map(|c| (c.order_key(), c.key.clone()))
766 .collect::<BTreeMap<_, _>>()
767 .into_values()
768 .collect();
769 let mut winners: Vec<(String, String, Vec<String>)> = Vec::new();
770 for key in exclusive {
771 let Some(claim) = claims.get_mut(&key) else {
772 continue;
773 };
774 let beaten = winners.iter().find(|(_, session, scope)| {
775 *session != claim.session && scopes_meet(scope, &claim.scope)
776 });
777 match beaten {
778 Some((winner, _, _)) => claim.state = ClaimState::Conflicted(winner.clone()),
779 None => {
780 claim.state = ClaimState::Held;
781 winners.push((
782 claim.key.clone(),
783 claim.session.clone(),
784 claim.scope.clone(),
785 ));
786 }
787 }
788 }
789
790 // Advisory overlaps: live claims of different sessions that meet, not both exclusive.
791 let live: Vec<&ClaimView> = claims
792 .values()
793 .filter(|c| c.state.is_live())
794 .map(|c| (c.order_key(), c))
795 .collect::<BTreeMap<_, _>>()
796 .into_values()
797 .collect();
798 let mut overlaps = Vec::new();
799 for (i, first) in live.iter().enumerate() {
800 for second in &live[i + 1..] {
801 if first.session == second.session
802 || (first.mode == ClaimMode::Exclusive && second.mode == ClaimMode::Exclusive)
803 {
804 continue;
805 }
806 let paths = meeting_paths(&first.scope, &second.scope);
807 if !paths.is_empty() {
808 overlaps.push(ClaimOverlap {
809 first: first.key.clone(),
810 second: second.key.clone(),
811 paths,
812 });
813 }
814 }
815 }
816
817 let mut state = CooperationState {
818 sessions: sessions.into_values().collect(),
819 claims: claims.into_values().collect(),
820 overlaps,
821 handovers: handovers.into_values().collect(),
822 reviews: reviews.into_values().collect(),
823 opaque_events: opaque,
824 digest: String::new(),
825 };
826 state.digest = digest(&state);
827 state
828}
829
830fn digest(state: &CooperationState) -> String {
831 use sha2::{Digest, Sha256};
832 let value = serde_json::to_value(state).unwrap_or_default();
833 let hash = Sha256::digest(canonical_json(&value));
834 hash.iter().take(16).map(|b| format!("{b:02x}")).collect()
835}
836
837/// Whether any path of one scope meets any path of the other. Paths meet by
838/// [`crate::peers::claims_meet`], the peer board's own predicate, so that a claim means the
839/// same thing on this machine and on another: a directory contains what is under it, and
840/// two claims that share no path do not meet however similar they look.
841///
842/// ```
843/// use majordomus_cli::mesh::state::scopes_meet;
844///
845/// assert!(scopes_meet(&["apps".into()], &["apps/majordomus-cli/src".into()]), "a tree contains it");
846/// assert!(!scopes_meet(&["apps".into()], &["docs".into()]));
847/// assert!(!scopes_meet(&[], &["apps".into()]), "a claim over nothing meets nothing");
848/// ```
849pub fn scopes_meet(a: &[String], b: &[String]) -> bool {
850 a.iter()
851 .any(|x| b.iter().any(|y| crate::peers::claims_meet(x, y)))
852}
853
854fn meeting_paths(a: &[String], b: &[String]) -> Vec<(String, String)> {
855 let mut out = Vec::new();
856 for x in a {
857 for y in b {
858 if crate::peers::claims_meet(x, y) {
859 out.push((x.clone(), y.clone()));
860 }
861 }
862 }
863 out
864}
865
866/// The live exclusive claims of other sessions a new exclusive claim of `session` over
867/// `scope` would meet: empty means admissible. An advisory claim is always admissible.
868///
869/// This is the difference between refusing a claim and reporting a conflict after the
870/// fact. Admission is checked against the folded state before anything is written, so a
871/// claim that could have been refused is refused; what is left — genuinely concurrent
872/// claims from two sides of a partition — is what the fold ranks afterwards, and both
873/// runtimes name the same winner.
874///
875/// ```
876/// use std::sync::Arc;
877/// use majordomus_cli::mesh::identity::NodeIdentity;
878/// use majordomus_cli::mesh::journal::{ClaimMode, EventBody, Journal, StreamLiveness};
879/// use majordomus_cli::mesh::state::{admission_conflicts, fold};
880///
881/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
882/// "repo".into(), None).unwrap();
883/// j.append_own(EventBody::ClaimAcquired { claim: "c1".into(), session: "s1".into(),
884/// scope: vec!["apps".into()], intent: None, mode: ClaimMode::Exclusive, issue: None })
885/// .unwrap();
886/// let state = fold(&j.events(), &|_| StreamLiveness::Own);
887/// let held = &state.claims[0];
888///
889/// // another session cannot take a scope inside one already held exclusively
890/// let met = admission_conflicts(&state, "s2", &["apps/majordomus-cli".into()],
891/// ClaimMode::Exclusive);
892/// assert_eq!(met.len(), 1);
893/// assert_eq!(met[0].key, held.key, "the refusal names what it met");
894///
895/// // the holder is not in its own way, and an advisory claim is never refused
896/// assert!(admission_conflicts(&state, &held.session, &["apps".into()], ClaimMode::Exclusive)
897/// .is_empty());
898/// assert!(admission_conflicts(&state, "s2", &["apps".into()], ClaimMode::Advisory).is_empty());
899/// ```
900pub fn admission_conflicts<'a>(
901 state: &'a CooperationState,
902 session: &str,
903 scope: &[String],
904 mode: ClaimMode,
905) -> Vec<&'a ClaimView> {
906 if mode == ClaimMode::Advisory {
907 return Vec::new();
908 }
909 state
910 .claims
911 .iter()
912 .filter(|c| {
913 c.state.is_live()
914 && c.mode == ClaimMode::Exclusive
915 && c.session != session
916 && scopes_meet(&c.scope, scope)
917 })
918 .collect()
919}
920
921#[cfg(test)]
922mod tests {
923 use super::*;
924 use crate::mesh::identity::NodeIdentity;
925 use crate::mesh::journal::{Journal, Rejection};
926 use std::sync::Arc;
927
928 fn journal(runtime: &str) -> Journal {
929 Journal::open(
930 Arc::new(NodeIdentity::ephemeral().unwrap()),
931 runtime,
932 "repo".into(),
933 None,
934 )
935 .unwrap()
936 }
937
938 fn claim(j: &Journal, claim: &str, session: &str, scope: &[&str], mode: ClaimMode) {
939 j.append_own(EventBody::ClaimAcquired {
940 claim: claim.into(),
941 session: session.into(),
942 scope: scope.iter().map(|s| s.to_string()).collect(),
943 intent: None,
944 mode,
945 issue: Some("#184".into()),
946 })
947 .unwrap();
948 }
949
950 fn sync(from: &Journal, to: &Journal) {
951 to.ingest(
952 &from.missing_for(&to.marks(), usize::MAX),
953 &|_: &MeshEvent| Ok::<(), Rejection>(()),
954 );
955 }
956
957 fn all_live(_: &StreamId) -> StreamLiveness {
958 StreamLiveness::Live
959 }
960
961 #[test]
962 fn a_remote_claim_is_seen_and_refuses_a_conflicting_admission() {
963 let a = journal("0000000000000001");
964 let b = journal("0000000000000002");
965 claim(
966 &a,
967 "c1",
968 "s1",
969 &["apps/majordomus-cli"],
970 ClaimMode::Exclusive,
971 );
972 sync(&a, &b);
973 let state = fold(&b.events(), &all_live);
974 assert_eq!(state.claims.len(), 1);
975 assert_eq!(state.claims[0].state, ClaimState::Held);
976 let conflicts = admission_conflicts(
977 &state,
978 &format!("{}/s9", b.own_stream()),
979 &["apps".into()],
980 ClaimMode::Exclusive,
981 );
982 assert_eq!(
983 conflicts.len(),
984 1,
985 "B's exclusive claim over apps meets A's"
986 );
987 assert!(
988 admission_conflicts(&state, "x", &["docs".into()], ClaimMode::Exclusive).is_empty()
989 );
990 assert!(
991 admission_conflicts(&state, "x", &["apps".into()], ClaimMode::Advisory).is_empty(),
992 "advisory never refuses"
993 );
994 }
995
996 #[test]
997 fn a_dead_holder_releases_nothing_yet_its_claim_expires() {
998 let a = journal("0000000000000001");
999 let b = journal("0000000000000002");
1000 claim(&a, "c1", "s1", &["apps"], ClaimMode::Exclusive);
1001 sync(&a, &b);
1002 let dead = fold(&b.events(), &|_| StreamLiveness::Expired);
1003 assert!(matches!(dead.claims[0].state, ClaimState::Expired(_)));
1004 assert!(
1005 admission_conflicts(&dead, "other", &["apps".into()], ClaimMode::Exclusive).is_empty(),
1006 "an expired lease no longer excludes"
1007 );
1008 }
1009
1010 #[test]
1011 fn concurrent_claims_name_the_same_winner_everywhere() {
1012 // Two sides of a partition claim the same scope; after healing, both runtimes fold
1013 // the same events and agree on the winner, regardless of who synced first.
1014 let a = journal("0000000000000001");
1015 let b = journal("0000000000000002");
1016 claim(&a, "c1", "s1", &["apps"], ClaimMode::Exclusive);
1017 claim(
1018 &b,
1019 "c1",
1020 "s1",
1021 &["apps/majordomus-cli"],
1022 ClaimMode::Exclusive,
1023 );
1024 sync(&a, &b);
1025 sync(&b, &a);
1026 let at_a = fold(&a.events(), &all_live);
1027 let at_b = fold(&b.events(), &all_live);
1028 assert_eq!(at_a.digest, at_b.digest, "both runtimes converge");
1029 let held: Vec<_> = at_a
1030 .claims
1031 .iter()
1032 .filter(|c| c.state == ClaimState::Held)
1033 .collect();
1034 let conflicted: Vec<_> = at_a
1035 .claims
1036 .iter()
1037 .filter(|c| matches!(c.state, ClaimState::Conflicted(_)))
1038 .collect();
1039 assert_eq!((held.len(), conflicted.len()), (1, 1));
1040 assert_eq!(
1041 conflicted[0].state,
1042 ClaimState::Conflicted(held[0].key.clone())
1043 );
1044 }
1045
1046 #[test]
1047 fn release_and_session_close_end_claims() {
1048 let a = journal("0000000000000001");
1049 claim(&a, "c1", "s1", &["apps"], ClaimMode::Exclusive);
1050 claim(&a, "c2", "s2", &["docs"], ClaimMode::Exclusive);
1051 a.append_own(EventBody::SessionOpened {
1052 info: SessionInfo::named("s2", "test"),
1053 })
1054 .unwrap();
1055 a.append_own(EventBody::ClaimReleased { claim: "c1".into() })
1056 .unwrap();
1057 a.append_own(EventBody::SessionClosed {
1058 session: "s2".into(),
1059 })
1060 .unwrap();
1061 let state = fold(&a.events(), &|_| StreamLiveness::Own);
1062 assert_eq!(state.claims[0].state, ClaimState::Released);
1063 assert!(matches!(state.claims[1].state, ClaimState::Expired(_)));
1064 assert_eq!(state.sessions[0].state, SessionState::Closed);
1065 }
1066
1067 #[test]
1068 fn a_reopened_session_id_does_not_bring_back_the_claims_its_close_ended() {
1069 let a = journal("0000000000000001");
1070 claim(&a, "c1", "p1", &["apps"], ClaimMode::Exclusive);
1071 a.append_own(EventBody::SessionClosed {
1072 session: "p1".into(),
1073 })
1074 .unwrap();
1075 a.append_own(EventBody::SessionOpened {
1076 info: SessionInfo::named("p1", "test"),
1077 })
1078 .unwrap();
1079 let state = fold(&a.events(), &|_| StreamLiveness::Own);
1080 assert!(matches!(state.claims[0].state, ClaimState::Expired(_)));
1081 claim(&a, "c2", "p1", &["apps"], ClaimMode::Exclusive);
1082 let state = fold(&a.events(), &|_| StreamLiveness::Own);
1083 let second = state
1084 .claims
1085 .iter()
1086 .find(|c| c.key.ends_with("/c2"))
1087 .unwrap();
1088 assert_eq!(
1089 second.state,
1090 ClaimState::Held,
1091 "a claim made after the reopen holds"
1092 );
1093 }
1094
1095 #[test]
1096 fn advisory_overlaps_are_reported_not_refused() {
1097 let a = journal("0000000000000001");
1098 claim(&a, "c1", "s1", &["apps"], ClaimMode::Advisory);
1099 claim(&a, "c2", "s2", &["apps/x"], ClaimMode::Exclusive);
1100 let state = fold(&a.events(), &|_| StreamLiveness::Own);
1101 assert!(state.claims.iter().all(|c| c.state == ClaimState::Held));
1102 assert_eq!(state.overlaps.len(), 1);
1103 assert_eq!(
1104 state.overlaps[0].paths,
1105 vec![("apps".into(), "apps/x".into())]
1106 );
1107 }
1108
1109 #[test]
1110 fn handovers_and_reviews_link_across_streams() {
1111 let a = journal("0000000000000001");
1112 let b = journal("0000000000000002");
1113 let body = "# Objective\nship\n# Current State\nhalf\n# Next Action\nrest\n".to_string();
1114 let id = HandoverBody::digest_of(&body);
1115 a.append_own(EventBody::HandoverPublished {
1116 handover: HandoverBody {
1117 id: id.clone(),
1118 task: Some("t-1".into()),
1119 issue: Some("#184".into()),
1120 milestone: None,
1121 branch: Some("feature/x".into()),
1122 head: Some("abc".into()),
1123 created_at: None,
1124 name: None,
1125 body: body.clone(),
1126 },
1127 })
1128 .unwrap();
1129 a.append_own(EventBody::ReviewRequested {
1130 review: "r1".into(),
1131 session: "s1".into(),
1132 subject: "feature/x".into(),
1133 scope: vec!["apps".into()],
1134 issue: Some("#184".into()),
1135 reviewer: None,
1136 body: None,
1137 })
1138 .unwrap();
1139 sync(&a, &b);
1140 b.append_own(EventBody::HandoverConsumed {
1141 handover: id.clone(),
1142 session: "s2".into(),
1143 })
1144 .unwrap();
1145 b.append_own(EventBody::ReviewAnswered {
1146 request: format!("{}/r1", a.own_stream()),
1147 session: "s2".into(),
1148 verdict: "approved".into(),
1149 note: None,
1150 })
1151 .unwrap();
1152 sync(&b, &a);
1153 let at_a = fold(&a.events(), &all_live);
1154 assert_eq!(
1155 at_a.handovers[0].consumed_by,
1156 vec![format!("{}/s2", b.own_stream())]
1157 );
1158 assert_eq!(at_a.reviews[0].state, ReviewState::Answered);
1159 assert_eq!(at_a.reviews[0].answers[0].verdict, "approved");
1160 assert_eq!(at_a.digest, fold(&b.events(), &all_live).digest);
1161 }
1162
1163 #[test]
1164 fn a_review_request_ends_by_withdrawal_or_with_its_asker() {
1165 let a = journal("0000000000000001");
1166 let b = journal("0000000000000002");
1167 let ask = |j: &Journal, review: &str, session: &str| {
1168 j.append_own(EventBody::ReviewRequested {
1169 review: review.into(),
1170 session: session.into(),
1171 subject: format!("subject {review}"),
1172 scope: vec![],
1173 issue: None,
1174 reviewer: None,
1175 body: Some(format!("{}\nthe evidence", "b".repeat(8000))),
1176 })
1177 .unwrap();
1178 };
1179 let open = |j: &Journal, session: &str| {
1180 j.append_own(EventBody::SessionOpened {
1181 info: SessionInfo {
1182 session: session.into(),
1183 client: "test".into(),
1184 ..SessionInfo::default()
1185 },
1186 })
1187 .unwrap();
1188 };
1189 open(&a, "s1");
1190 open(&a, "s3");
1191 ask(&a, "r1", "s1"); // withdrawn by its asker, with a result
1192 ask(&a, "r2", "s1"); // withdrawn by another session of the same runtime (a reconnect)
1193 ask(&a, "r3", "s3"); // its session closes: a lane request outlives it, still open
1194 ask(&a, "r4", "s3"); // a withdrawal written by another runtime: ignored
1195 ask(&a, "r5", "s3"); // answered, then the session closes: stays answered
1196 let r = |n: &str| format!("{}/{n}", a.own_stream());
1197 a.append_own(EventBody::ReviewWithdrawn {
1198 request: r("r1"),
1199 session: "s1".into(),
1200 note: Some("superseded by the rebuilt head".into()),
1201 })
1202 .unwrap();
1203 a.append_own(EventBody::ReviewWithdrawn {
1204 request: r("r2"),
1205 session: "s2".into(),
1206 note: None,
1207 })
1208 .unwrap();
1209 sync(&a, &b);
1210 b.append_own(EventBody::ReviewWithdrawn {
1211 request: r("r4"),
1212 session: "s1".into(),
1213 note: Some("not mine to end".into()),
1214 })
1215 .unwrap();
1216 b.append_own(EventBody::ReviewAnswered {
1217 request: r("r5"),
1218 session: "s9".into(),
1219 verdict: "approved".into(),
1220 note: None,
1221 })
1222 .unwrap();
1223 sync(&b, &a);
1224 a.append_own(EventBody::SessionClosed {
1225 session: "s3".into(),
1226 })
1227 .unwrap();
1228 sync(&a, &b);
1229
1230 let at_a = fold(&a.events(), &all_live);
1231 let at_b = fold(&b.events(), &all_live);
1232 assert_eq!(at_a.digest, at_b.digest, "both runtimes fold the same ends");
1233 let state_of = |n: &str| {
1234 at_a.reviews
1235 .iter()
1236 .find(|v| v.key == r(n))
1237 .map(|v| v.state)
1238 .unwrap()
1239 };
1240 assert_eq!(state_of("r1"), ReviewState::Withdrawn);
1241 assert_eq!(
1242 state_of("r2"),
1243 ReviewState::Withdrawn,
1244 "any session of the asking runtime"
1245 );
1246 assert_eq!(
1247 state_of("r3"),
1248 ReviewState::Open,
1249 "a closed session does not end it"
1250 );
1251 assert_eq!(
1252 state_of("r4"),
1253 ReviewState::Open,
1254 "only the asking runtime withdraws"
1255 );
1256 assert_eq!(state_of("r5"), ReviewState::Answered);
1257 let r1 = at_b.reviews.iter().find(|v| v.key == r("r1")).unwrap();
1258 assert_eq!(r1.result.as_deref(), Some("superseded by the rebuilt head"));
1259 assert!(r1.answers.is_empty(), "a result is not an answer");
1260 assert!(
1261 r1.body.as_deref().is_some_and(|b| b.len() > 8000),
1262 "an 8 KiB body replicates and folds"
1263 );
1264
1265 // where the asking runtime stopped beating, an unanswered request has ended; one that
1266 // was answered or withdrawn keeps that ending
1267 let elsewhere = fold(&b.events(), &|s: &StreamId| {
1268 if s == a.own_stream() {
1269 StreamLiveness::Expired
1270 } else {
1271 StreamLiveness::Live
1272 }
1273 });
1274 let at = |n: &str| {
1275 elsewhere
1276 .reviews
1277 .iter()
1278 .find(|v| v.key == r(n))
1279 .unwrap()
1280 .state
1281 };
1282 assert_eq!(at("r3"), ReviewState::Expired);
1283 assert_eq!(at("r1"), ReviewState::Withdrawn);
1284 assert_eq!(at("r5"), ReviewState::Answered);
1285 }
1286
1287 #[test]
1288 fn a_body_or_result_over_the_bound_is_refused() {
1289 let over = "x".repeat(crate::mesh::journal::REVIEW_BODY_MAX + 1);
1290 let asked = EventBody::ReviewRequested {
1291 review: "r1".into(),
1292 session: "s1".into(),
1293 subject: "s".into(),
1294 scope: vec![],
1295 issue: None,
1296 reviewer: None,
1297 body: Some(over.clone()),
1298 };
1299 assert!(asked.validate().is_err());
1300 let withdrawn = EventBody::ReviewWithdrawn {
1301 request: "a/r1".into(),
1302 session: "s1".into(),
1303 note: Some(over),
1304 };
1305 assert!(withdrawn.validate().is_err());
1306 assert_eq!(withdrawn.kind(), "review_withdrawn");
1307 assert!(crate::mesh::journal::KNOWN_KINDS.contains(&withdrawn.kind()));
1308 }
1309
1310 #[test]
1311 fn the_session_that_asked_for_a_review_cannot_answer_it() {
1312 let a = journal("0000000000000001");
1313 a.append_own(EventBody::ReviewRequested {
1314 review: "r1".into(),
1315 session: "s1".into(),
1316 subject: "feature/x".into(),
1317 scope: vec![],
1318 issue: None,
1319 reviewer: None,
1320 body: None,
1321 })
1322 .unwrap();
1323 a.append_own(EventBody::ReviewAnswered {
1324 request: format!("{}/r1", a.own_stream()),
1325 session: "s1".into(),
1326 verdict: "approved".into(),
1327 note: None,
1328 })
1329 .unwrap();
1330 let state = fold(&a.events(), &|_| StreamLiveness::Own);
1331 assert_eq!(state.reviews[0].state, ReviewState::Open);
1332 assert!(state.reviews[0].answers.is_empty());
1333 }
1334
1335 proptest::proptest! {
1336 /// Folding any permutation of the same events, with any duplicates, yields the same
1337 /// state: arrival order can never change canonical state.
1338 #[test]
1339 fn fold_is_order_and_duplicate_independent(
1340 order in proptest::collection::vec(0usize..8, 8..32),
1341 dead in proptest::prelude::any::<bool>(),
1342 ) {
1343 let a = journal("0000000000000001");
1344 let b = journal("0000000000000002");
1345 claim(&a, "c1", "s1", &["apps"], ClaimMode::Exclusive);
1346 claim(&b, "c1", "s1", &["apps/majordomus-cli"], ClaimMode::Exclusive);
1347 claim(&a, "c2", "s1", &["docs"], ClaimMode::Advisory);
1348 claim(&b, "c2", "s2", &["docs/MESH.md"], ClaimMode::Advisory);
1349 a.append_own(EventBody::ClaimReleased { claim: "c2".into() }).unwrap();
1350 b.append_own(EventBody::SessionOpened { info: SessionInfo::named("s2", "t") }).unwrap();
1351 a.append_own(EventBody::SessionOpened { info: SessionInfo::named("s1", "t") }).unwrap();
1352 b.append_own(EventBody::SessionClosed { session: "s2".into() }).unwrap();
1353 let mut events = a.events();
1354 events.extend(b.events());
1355 let dead_stream = a.own_stream().clone();
1356 let live = move |s: &StreamId| if dead && *s == dead_stream { StreamLiveness::Expired } else { StreamLiveness::Live };
1357 let reference = fold(&events, &live);
1358 let shuffled: Vec<MeshEvent> = order.iter().map(|i| events[*i].clone()).chain(events.iter().cloned()).collect();
1359 proptest::prop_assert_eq!(fold(&shuffled, &live), reference);
1360 }
1361
1362 /// However claims interleave, no scope ever has two held exclusive winners from
1363 /// different sessions.
1364 #[test]
1365 fn exclusivity_holds_for_any_interleaving(picks in proptest::collection::vec((0usize..3, 0usize..4), 1..20)) {
1366 let journals = [journal("0000000000000001"), journal("0000000000000002"), journal("0000000000000003")];
1367 let scopes = ["apps", "apps/x", "docs", "apps/x/y"];
1368 for (i, (who, scope)) in picks.iter().enumerate() {
1369 claim(&journals[*who], &format!("c{i}"), &format!("s{who}"), &[scopes[*scope]], ClaimMode::Exclusive);
1370 }
1371 let events: Vec<MeshEvent> = journals.iter().flat_map(|j| j.events()).collect();
1372 let state = fold(&events, &all_live);
1373 let held: Vec<&ClaimView> = state.claims.iter().filter(|c| c.state == ClaimState::Held).collect();
1374 for (i, x) in held.iter().enumerate() {
1375 for y in &held[i + 1..] {
1376 proptest::prop_assert!(x.session == y.session || !scopes_meet(&x.scope, &y.scope));
1377 }
1378 }
1379 }
1380 }
1381}