Skip to main content

majordomus_cli/mesh/
page.rs

1//! What one answer about the mesh carries: a summary a worker can read whole, or the folded
2//! state narrowed to what was asked.
3//!
4//! On 2026-10-10 `mesh.state` answered 449 kB and `mesh.peers` 313 kB on a mesh of twelve
5//! runtimes: 165 claims, half of them released, 126 sessions, 109 of them closed. A worker
6//! asking "who holds what" over MCP got an answer its client could not show, and a page of
7//! 80 journal events was 83 000 characters, 30 of them one session reopening itself. The
8//! fold is not wrong to keep its history; an answer is wrong to carry all of it by default.
9//!
10//! So the default is a summary that does not grow with history — per runtime its liveness,
11//! its sessions counted by standing and its newest live claim; the totals; the open review
12//! requests and the handovers nobody has taken — bounded by [`SUMMARY_LIST`] and
13//! [`SUMMARY_TEXT`]. The whole state is still one input away, and the filters
14//! narrow it without changing its shape. The digest is always the digest of the whole fold,
15//! never of a page, because it is what two runtimes compare.
16//!
17//! Everything here is pure: the same state, liveness and query give the same answer. A
18//! capability folds the journal, narrows the state to the query, and answers either the
19//! narrowed state or its summary:
20//!
21//! ```
22//! use std::sync::Arc;
23//! use majordomus_cli::mesh::identity::NodeIdentity;
24//! use majordomus_cli::mesh::journal::{EventBody, Journal, SessionInfo, StreamLiveness};
25//! use majordomus_cli::mesh::page::{narrow, summarize, Query, View};
26//! use majordomus_cli::mesh::state::fold;
27//!
28//! let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
29//!     "repo".into(), None).unwrap();
30//! for n in 0..3 {
31//!     j.append_own(EventBody::SessionOpened { info: SessionInfo::named(&format!("s{n}"), "cli") })
32//!         .unwrap();
33//! }
34//! j.append_own(EventBody::SessionClosed { session: "s0".into() }).unwrap();
35//! let state = fold(&j.events(), &|_| StreamLiveness::Own);
36//!
37//! let query = Query { open: Some(true), ..Query::default() };
38//! let narrowed = narrow(&state, &query);
39//! assert_eq!(narrowed.sessions.len(), 2, "the closed session is left out");
40//! assert_eq!(query.view(), View::Summary);
41//! let summary = summarize(&narrowed, &[]);
42//! assert_eq!(summary.sessions.active, 2);
43//! assert_eq!(summary.digest, state.digest, "always the whole fold's digest");
44//! ```
45
46use std::collections::BTreeMap;
47
48use schemars::JsonSchema;
49use serde::{Deserialize, Serialize};
50
51use super::journal::StreamLiveness;
52use super::state::{ClaimState, CooperationState, ReviewState, SessionState};
53
54/// The most entries a summary lists in any one list; what is past it is counted.
55pub const SUMMARY_LIST: usize = 24;
56
57/// The most characters a summary keeps of an intent or a subject; the rest is cut with `…`.
58pub const SUMMARY_TEXT: usize = 160;
59
60/// How much an answer about the mesh carries. The summary is the default because it is the
61/// answer a worker can read whole whatever the mesh's history; the full fold is one word
62/// away when a reader needs every record.
63///
64/// ```
65/// use majordomus_cli::mesh::page::View;
66///
67/// assert_eq!(View::default(), View::Summary);
68/// assert_eq!(serde_json::to_value(View::Full).unwrap(), "full");
69/// ```
70#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
71#[serde(rename_all = "snake_case")]
72#[schemars(rename = "MeshView")]
73pub enum View {
74    /// Counts, each runtime's newest live claim, the open reviews and the untaken handovers:
75    /// bounded, whatever the history.
76    #[default]
77    Summary,
78    /// Everything, narrowed only by the filters.
79    Full,
80}
81
82/// Which claims a full answer keeps: all of them, the ended ones included, or only those
83/// still live — the question an executor asks before it takes a lease.
84///
85/// ```
86/// use majordomus_cli::mesh::page::ClaimFilter;
87///
88/// assert_eq!(ClaimFilter::default(), ClaimFilter::All);
89/// assert_eq!(serde_json::from_str::<ClaimFilter>(r#""live""#).unwrap(), ClaimFilter::Live);
90/// ```
91#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
92#[serde(rename_all = "snake_case")]
93#[schemars(rename = "MeshClaimFilter")]
94pub enum ClaimFilter {
95    /// Every claim, ended ones included.
96    #[default]
97    All,
98    /// Only claims that are still live: held, or conflicted and still held.
99    Live,
100}
101
102/// One collection of the folded state, as `kind` names it: a reader that wants only the
103/// reviews is not sent the claims.
104///
105/// ```
106/// use majordomus_cli::mesh::page::Kind;
107///
108/// assert_eq!(serde_json::from_str::<Kind>(r#""reviews""#).unwrap(), Kind::Reviews);
109/// assert!(serde_json::from_str::<Kind>(r#""everything""#).is_err());
110/// ```
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
112#[serde(rename_all = "snake_case")]
113#[schemars(rename = "MeshKind")]
114pub enum Kind {
115    /// The sessions.
116    Sessions,
117    /// The claims.
118    Claims,
119    /// The advisory overlaps.
120    Overlaps,
121    /// The handovers.
122    Handovers,
123    /// The review requests.
124    Reviews,
125}
126
127/// What a reader asks of the folded state. Every filter narrows; none changes a shape, and
128/// an input it does not know is refused rather than ignored.
129///
130/// ```
131/// use majordomus_cli::mesh::page::{ClaimFilter, Query, View};
132///
133/// let q: Query = serde_json::from_str(r#"{"view":"full","claims":"live","limit":5}"#).unwrap();
134/// assert_eq!(q.view(), View::Full);
135/// assert_eq!((q.claims, q.limit), (Some(ClaimFilter::Live), Some(5)));
136/// assert!(serde_json::from_str::<Query>(r#"{"views":"full"}"#).is_err());
137/// ```
138#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
139#[serde(deny_unknown_fields)]
140#[schemars(rename = "MeshQuery")]
141pub struct Query {
142    /// `summary` (the default) or `full`.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub view: Option<View>,
145    /// `all` (the default) or `live`: whether ended claims are kept.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub claims: Option<ClaimFilter>,
148    /// Only sessions, claims and handovers whose latest Lamport stamp is above this. Reviews
149    /// and overlaps carry no stamp of their own and are kept or dropped with their sessions.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub after: Option<u64>,
152    /// Only what belongs to this session: its key (`<stream>/<session>`) or its bare id.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub session: Option<String>,
155    /// Only what belongs to this runtime: `<node>-<runtime>`, or a node id for every runtime
156    /// of that machine.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub runtime: Option<String>,
159    /// Only this collection; the others are answered empty.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub kind: Option<Kind>,
162    /// At most this many of each collection: the newest by Lamport stamp where it has one,
163    /// kept in the fold's own order.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub limit: Option<usize>,
166    /// Only what is still open: active sessions, live claims, review requests nobody has
167    /// answered and handovers nobody has taken.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub open: Option<bool>,
170    /// Only what mentions this text, ignoring case: a path, a branch, an issue, a pull
171    /// request, a word of an intent or a subject.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub matching: Option<String>,
174}
175
176impl crate::capability::benchmark::BenchmarkCases for Query {
177    fn benchmark_cases(
178        _: &crate::capability::benchmark::CaseContext<'_>,
179    ) -> Vec<crate::capability::benchmark::NamedCase<Self>> {
180        use crate::capability::benchmark::NamedCase;
181        vec![
182            NamedCase::new("summary", Query::default()),
183            NamedCase::new(
184                "full",
185                Query {
186                    view: Some(View::Full),
187                    ..Query::default()
188                },
189            ),
190        ]
191    }
192}
193
194impl Query {
195    /// The view asked for, the summary when none was: the one place the default is decided,
196    /// so every capability that takes a query answers the same view for the same input.
197    ///
198    /// ```
199    /// use majordomus_cli::mesh::page::{Query, View};
200    ///
201    /// assert_eq!(Query::default().view(), View::Summary);
202    /// let full = Query { view: Some(View::Full), ..Query::default() };
203    /// assert_eq!(full.view(), View::Full);
204    /// ```
205    pub fn view(&self) -> View {
206        self.view.unwrap_or_default()
207    }
208
209    /// Whether `key` (`<stream>/<session>`) is the session asked about, or nothing was.
210    pub(crate) fn admits_session(&self, key: &str) -> bool {
211        self.session.as_deref().is_none_or(|wanted| {
212            key == wanted || key.rsplit_once('/').is_some_and(|(_, id)| id == wanted)
213        })
214    }
215
216    /// Whether `runtime` (`<node>-<runtime>`) is the runtime asked about, or nothing was.
217    pub(crate) fn admits_runtime(&self, runtime: &str) -> bool {
218        self.runtime
219            .as_deref()
220            .is_none_or(|wanted| runtime == wanted || runtime.split('-').next() == Some(wanted))
221    }
222
223    fn admits_lamport(&self, lamport: u64) -> bool {
224        self.after.is_none_or(|after| lamport > after)
225    }
226
227    fn admits_kind(&self, kind: Kind) -> bool {
228        self.kind.is_none_or(|k| k == kind)
229    }
230
231    fn open_only(&self) -> bool {
232        self.open == Some(true)
233    }
234
235    /// Whether any of `texts` mentions the text asked for, or nothing was asked.
236    fn admits_text<'a>(&self, texts: impl IntoIterator<Item = Option<&'a str>>) -> bool {
237        let Some(wanted) = self.matching.as_deref().map(str::to_lowercase) else {
238            return true;
239        };
240        texts
241            .into_iter()
242            .flatten()
243            .any(|text| text.to_lowercase().contains(&wanted))
244    }
245}
246
247/// The newest `limit` of `items` by `stamp`, in their own order; all of them without a limit.
248fn newest<T>(items: Vec<T>, limit: Option<usize>, stamp: impl Fn(&T) -> u64) -> Vec<T> {
249    let Some(limit) = limit else {
250        return items;
251    };
252    // the `limit`-th highest stamp, popped from a heap rather than sorted out of a list
253    let mut stamps: std::collections::BinaryHeap<u64> = items.iter().map(&stamp).collect();
254    if stamps.len() <= limit {
255        return items;
256    }
257    let floor = (0..limit.max(1))
258        .filter_map(|_| stamps.pop())
259        .last()
260        .unwrap_or(u64::MAX);
261    // the stamp at the limit admits ties, so equal stamps are kept or dropped together
262    // until the count is reached, in the fold's own order
263    let mut kept = 0;
264    items
265        .into_iter()
266        .filter(|item| {
267            let keep = stamp(item) >= floor && kept < limit;
268            kept += usize::from(keep);
269            keep
270        })
271        .collect()
272}
273
274/// The state narrowed to `query`. The digest is the whole fold's, carried unchanged: it is
275/// what two runtimes compare, and a page of the state is not a state of its own.
276///
277/// ```
278/// use std::sync::Arc;
279/// use majordomus_cli::mesh::identity::NodeIdentity;
280/// use majordomus_cli::mesh::journal::{ClaimMode, EventBody, Journal, SessionInfo, StreamLiveness};
281/// use majordomus_cli::mesh::state::fold;
282///
283/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
284///     "repo".into(), None).unwrap();
285/// j.append_own(EventBody::SessionOpened { info: SessionInfo::named("s1", "claude-code") })
286///     .unwrap();
287/// for (claim, path) in [("c1", "apps/a"), ("c2", "apps/b")] {
288///     j.append_own(EventBody::ClaimAcquired { claim: claim.into(), session: "s1".into(),
289///         scope: vec![path.into()], intent: Some("work".into()), mode: ClaimMode::Exclusive,
290///         issue: Some("#1".into()) }).unwrap();
291/// }
292/// j.append_own(EventBody::ClaimReleased { claim: "c1".into() }).unwrap();
293/// let state = fold(&j.events(), &|_| StreamLiveness::Own);
294/// use majordomus_cli::mesh::page::{narrow, ClaimFilter, Query};
295///
296/// let live = narrow(&state, &Query { claims: Some(ClaimFilter::Live), ..Query::default() });
297/// assert_eq!(live.claims.len(), 1, "the released claim is left out");
298/// assert_eq!(live.digest, state.digest, "the whole fold's digest");
299/// ```
300pub fn narrow(state: &CooperationState, query: &Query) -> CooperationState {
301    let sessions: Vec<_> = state
302        .sessions
303        .iter()
304        .filter(|s| query.admits_session(&s.key) && query.admits_runtime(&s.runtime))
305        .filter(|s| query.admits_lamport(s.updated_lamport))
306        .filter(|s| !query.open_only() || s.state == SessionState::Active)
307        .filter(|s| {
308            let i = &s.info;
309            query.admits_text([
310                Some(s.key.as_str()),
311                i.intent.as_deref(),
312                i.issue.as_deref(),
313                i.milestone.as_deref(),
314                i.branch.as_deref(),
315                i.worker.as_deref(),
316            ])
317        })
318        .cloned()
319        .collect();
320    let claims: Vec<_> = state
321        .claims
322        .iter()
323        .filter(|c| query.admits_session(&c.session) && query.admits_runtime(&c.runtime))
324        .filter(|c| query.admits_lamport(c.acquired_lamport))
325        .filter(|c| query.claims != Some(ClaimFilter::Live) || c.state.is_live())
326        .filter(|c| !query.open_only() || c.state.is_live())
327        .filter(|c| {
328            query.admits_text(
329                c.scope
330                    .iter()
331                    .map(|p| Some(p.as_str()))
332                    .chain([c.intent.as_deref(), c.issue.as_deref()]),
333            )
334        })
335        .cloned()
336        .collect();
337    let kept: std::collections::BTreeSet<&str> = claims.iter().map(|c| c.key.as_str()).collect();
338    let overlaps: Vec<_> = state
339        .overlaps
340        .iter()
341        .filter(|o| kept.contains(o.first.as_str()) || kept.contains(o.second.as_str()))
342        .cloned()
343        .collect();
344    let handovers: Vec<_> = state
345        .handovers
346        .iter()
347        .filter(|h| query.admits_runtime(&h.runtime))
348        .filter(|h| query.admits_lamport(h.published_lamport))
349        .filter(|h| {
350            query.session.is_none() || h.consumed_by.iter().any(|s| query.admits_session(s))
351        })
352        .filter(|h| !query.open_only() || h.consumed_by.is_empty())
353        .filter(|h| {
354            let b = &h.handover;
355            query.admits_text([
356                b.branch.as_deref(),
357                b.issue.as_deref(),
358                b.milestone.as_deref(),
359                b.name.as_deref(),
360            ])
361        })
362        .cloned()
363        .collect();
364    let reviews: Vec<_> = state
365        .reviews
366        .iter()
367        .filter(|r| query.admits_session(&r.session))
368        .filter(|r| query.admits_runtime(runtime_of(&r.session)))
369        .filter(|r| query.after.is_none() || sessions.iter().any(|s| s.key == r.session))
370        .filter(|r| !query.open_only() || r.state == ReviewState::Open)
371        .filter(|r| {
372            query.admits_text(
373                r.scope
374                    .iter()
375                    .map(|p| Some(p.as_str()))
376                    .chain([Some(r.subject.as_str()), r.issue.as_deref()]),
377            )
378        })
379        .cloned()
380        .collect();
381    let only = |kind: Kind| query.admits_kind(kind);
382    CooperationState {
383        sessions: newest(sessions, query.limit, |s| s.updated_lamport)
384            .into_iter()
385            .filter(|_| only(Kind::Sessions))
386            .collect(),
387        claims: newest(claims, query.limit, |c| c.acquired_lamport)
388            .into_iter()
389            .filter(|_| only(Kind::Claims))
390            .collect(),
391        overlaps: newest(overlaps, query.limit, |_| 0)
392            .into_iter()
393            .filter(|_| only(Kind::Overlaps))
394            .collect(),
395        handovers: newest(handovers, query.limit, |h| h.published_lamport)
396            .into_iter()
397            .filter(|_| only(Kind::Handovers))
398            .collect(),
399        reviews: newest(reviews, query.limit, |_| 0)
400            .into_iter()
401            .filter(|_| only(Kind::Reviews))
402            .collect(),
403        opaque_events: state.opaque_events,
404        digest: state.digest.clone(),
405    }
406}
407
408/// The runtime a `<stream>/<id>` key was written by: its first 49 characters.
409fn runtime_of(key: &str) -> &str {
410    key.get(..49).unwrap_or(key)
411}
412
413/// `text` cut to [`SUMMARY_TEXT`] characters, with `…` where it was cut.
414pub(crate) fn cut(text: &str) -> String {
415    match text.char_indices().nth(SUMMARY_TEXT) {
416        Some((at, _)) => format!("{}…", &text[..at]),
417        None => text.to_string(),
418    }
419}
420
421/// Sessions by standing: how many are active, closed and expired. A count stays the same
422/// size however many sessions the mesh has seen, which is why the summary carries counts.
423///
424/// ```
425/// use majordomus_cli::mesh::page::SessionCounts;
426///
427/// let none = SessionCounts::default();
428/// assert_eq!(serde_json::to_value(&none).unwrap(),
429///     serde_json::json!({ "active": 0, "closed": 0, "expired": 0 }));
430/// ```
431#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
432#[schemars(rename = "MeshSessionCounts")]
433pub struct SessionCounts {
434    /// Open, on a runtime that beats.
435    pub active: usize,
436    /// Closed by their own runtime.
437    pub closed: usize,
438    /// Never closed, on a runtime that stopped beating.
439    pub expired: usize,
440}
441
442impl SessionCounts {
443    fn add(&mut self, state: SessionState) {
444        match state {
445            SessionState::Active => self.active += 1,
446            SessionState::Closed => self.closed += 1,
447            SessionState::Expired => self.expired += 1,
448        }
449    }
450}
451
452/// Claims by standing: held, conflicted, released and expired. Held and conflicted are
453/// live; the other two are history, counted rather than listed.
454///
455/// ```
456/// use majordomus_cli::mesh::page::ClaimCounts;
457///
458/// let counts = ClaimCounts { held: 2, released: 5, ..ClaimCounts::default() };
459/// assert_eq!(serde_json::to_value(&counts).unwrap()["released"], 5);
460/// ```
461#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
462#[schemars(rename = "MeshClaimCounts")]
463pub struct ClaimCounts {
464    /// Live and, for an exclusive claim, the winner of its scope.
465    pub held: usize,
466    /// Live, exclusive, and beaten to its scope.
467    pub conflicted: usize,
468    /// Released by the holder.
469    pub released: usize,
470    /// Ended without a release.
471    pub expired: usize,
472}
473
474impl ClaimCounts {
475    fn add(&mut self, state: &ClaimState) {
476        match state {
477            ClaimState::Held => self.held += 1,
478            ClaimState::Conflicted(_) => self.conflicted += 1,
479            ClaimState::Released => self.released += 1,
480            ClaimState::Expired(_) => self.expired += 1,
481        }
482    }
483}
484
485/// One live claim, told briefly: whose it is, what it is for and how many paths it holds,
486/// with its intent cut to [`SUMMARY_TEXT`] characters — enough to know whom to ask.
487///
488/// ```
489/// use std::sync::Arc;
490/// use majordomus_cli::mesh::identity::NodeIdentity;
491/// use majordomus_cli::mesh::journal::{ClaimMode, EventBody, Journal, SessionInfo, StreamLiveness};
492/// use majordomus_cli::mesh::state::fold;
493///
494/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
495///     "repo".into(), None).unwrap();
496/// j.append_own(EventBody::SessionOpened { info: SessionInfo::named("s1", "claude-code") })
497///     .unwrap();
498/// for (claim, path) in [("c1", "apps/a"), ("c2", "apps/b")] {
499///     j.append_own(EventBody::ClaimAcquired { claim: claim.into(), session: "s1".into(),
500///         scope: vec![path.into()], intent: Some("work".into()), mode: ClaimMode::Exclusive,
501///         issue: Some("#1".into()) }).unwrap();
502/// }
503/// j.append_own(EventBody::ClaimReleased { claim: "c1".into() }).unwrap();
504/// let state = fold(&j.events(), &|_| StreamLiveness::Own);
505/// use majordomus_cli::mesh::page::{summarize, ClaimBrief};
506///
507/// let summary = summarize(&state, &[]);
508/// let newest: &ClaimBrief = summary.runtimes[0].newest_claim.as_ref().unwrap();
509/// assert_eq!((newest.issue.as_deref(), newest.paths), (Some("#1"), 1));
510/// ```
511#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
512#[schemars(rename = "MeshClaimBrief")]
513pub struct ClaimBrief {
514    /// `<stream>/<claim>`.
515    pub key: String,
516    /// The holding session's key.
517    pub session: String,
518    /// The issue it is for.
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub issue: Option<String>,
521    /// What it is for, cut to [`SUMMARY_TEXT`] characters.
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub intent: Option<String>,
524    /// How many paths it claims.
525    pub paths: usize,
526    /// Exclusive or advisory.
527    pub mode: super::journal::ClaimMode,
528    /// The Lamport stamp of the acquisition.
529    pub acquired_lamport: u64,
530}
531
532/// One runtime, counted: its liveness and beat, its sessions and claims by standing, and its
533/// newest live claim. The summary lists these, the living runtimes first.
534///
535/// ```
536/// use std::sync::Arc;
537/// use majordomus_cli::mesh::identity::NodeIdentity;
538/// use majordomus_cli::mesh::journal::{ClaimMode, EventBody, Journal, SessionInfo, StreamLiveness};
539/// use majordomus_cli::mesh::state::fold;
540///
541/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
542///     "repo".into(), None).unwrap();
543/// j.append_own(EventBody::SessionOpened { info: SessionInfo::named("s1", "claude-code") })
544///     .unwrap();
545/// for (claim, path) in [("c1", "apps/a"), ("c2", "apps/b")] {
546///     j.append_own(EventBody::ClaimAcquired { claim: claim.into(), session: "s1".into(),
547///         scope: vec![path.into()], intent: Some("work".into()), mode: ClaimMode::Exclusive,
548///         issue: Some("#1".into()) }).unwrap();
549/// }
550/// j.append_own(EventBody::ClaimReleased { claim: "c1".into() }).unwrap();
551/// let state = fold(&j.events(), &|_| StreamLiveness::Own);
552/// use majordomus_cli::mesh::page::{summarize, RuntimeSummary};
553///
554/// let summary = summarize(&state, &[]);
555/// let runtime: &RuntimeSummary = &summary.runtimes[0];
556/// assert_eq!((runtime.sessions.active, runtime.claims.held, runtime.claims.released), (1, 1, 1));
557/// ```
558#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
559#[schemars(rename = "MeshRuntimeSummary")]
560pub struct RuntimeSummary {
561    /// `<node>-<runtime>`.
562    pub runtime: String,
563    /// Own, live or expired.
564    pub liveness: StreamLiveness,
565    /// Milliseconds since its beat last rose, on this runtime's clock.
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub last_beat_ms: Option<u64>,
568    /// Its sessions by standing.
569    pub sessions: SessionCounts,
570    /// Its claims by standing.
571    pub claims: ClaimCounts,
572    /// Its newest live claim.
573    #[serde(default, skip_serializing_if = "Option::is_none")]
574    pub newest_claim: Option<ClaimBrief>,
575}
576
577/// An open review request, told briefly: its key, what is to be reviewed and whom it asks.
578/// Only requests nobody has answered are listed in the summary.
579///
580/// ```
581/// use majordomus_cli::mesh::page::ReviewBrief;
582///
583/// let brief = ReviewBrief { key: "s/r1".into(), subject: "feature/x".into(), issue: None,
584///     reviewer: None };
585/// assert!(serde_json::to_value(&brief).unwrap().get("issue").is_none(), "absent, not null");
586/// ```
587#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
588#[schemars(rename = "MeshReviewBrief")]
589pub struct ReviewBrief {
590    /// `<stream>/<review>`.
591    pub key: String,
592    /// What is to be reviewed, cut to [`SUMMARY_TEXT`] characters.
593    pub subject: String,
594    /// The issue.
595    #[serde(default, skip_serializing_if = "Option::is_none")]
596    pub issue: Option<String>,
597    /// The runtime asked, when one was.
598    #[serde(default, skip_serializing_if = "Option::is_none")]
599    pub reviewer: Option<String>,
600}
601
602/// A handover nobody has taken, told briefly: the id `mesh.handover.consume` takes, who
603/// published it and what it continues. Its body stays in the full view.
604///
605/// ```
606/// use majordomus_cli::mesh::page::HandoverBrief;
607///
608/// let brief = HandoverBrief { id: "ab12".into(), runtime: "n-r".into(),
609///     branch: Some("feature/x".into()), issue: None, published_lamport: 7 };
610/// assert_eq!(serde_json::to_value(&brief).unwrap()["branch"], "feature/x");
611/// ```
612#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
613#[schemars(rename = "MeshHandoverBrief")]
614pub struct HandoverBrief {
615    /// The content digest: what `mesh.handover.consume` takes.
616    pub id: String,
617    /// `<node>-<runtime>` of the publisher.
618    pub runtime: String,
619    /// The branch it continues.
620    #[serde(default, skip_serializing_if = "Option::is_none")]
621    pub branch: Option<String>,
622    /// The issue it continues.
623    #[serde(default, skip_serializing_if = "Option::is_none")]
624    pub issue: Option<String>,
625    /// The Lamport stamp of the first publication.
626    pub published_lamport: u64,
627}
628
629/// The mesh in an answer a worker reads whole: counts, each runtime, the open reviews and
630/// the untaken handovers, every list bounded by [`SUMMARY_LIST`] with the rest counted.
631///
632/// ```
633/// use std::sync::Arc;
634/// use majordomus_cli::mesh::identity::NodeIdentity;
635/// use majordomus_cli::mesh::journal::{ClaimMode, EventBody, Journal, SessionInfo, StreamLiveness};
636/// use majordomus_cli::mesh::state::fold;
637///
638/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
639///     "repo".into(), None).unwrap();
640/// j.append_own(EventBody::SessionOpened { info: SessionInfo::named("s1", "claude-code") })
641///     .unwrap();
642/// for (claim, path) in [("c1", "apps/a"), ("c2", "apps/b")] {
643///     j.append_own(EventBody::ClaimAcquired { claim: claim.into(), session: "s1".into(),
644///         scope: vec![path.into()], intent: Some("work".into()), mode: ClaimMode::Exclusive,
645///         issue: Some("#1".into()) }).unwrap();
646/// }
647/// j.append_own(EventBody::ClaimReleased { claim: "c1".into() }).unwrap();
648/// let state = fold(&j.events(), &|_| StreamLiveness::Own);
649/// use majordomus_cli::mesh::page::{summarize, Summary};
650///
651/// let summary: Summary = summarize(&state, &[]);
652/// assert_eq!((summary.claims.held, summary.claims.released), (1, 1));
653/// assert_eq!(summary.digest, state.digest);
654/// ```
655#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
656#[schemars(rename = "MeshSummary")]
657pub struct Summary {
658    /// The whole fold's digest: equal digests, equal state.
659    pub digest: String,
660    /// Every session by standing.
661    pub sessions: SessionCounts,
662    /// Every claim by standing.
663    pub claims: ClaimCounts,
664    /// Advisory overlaps between live claims.
665    pub overlaps: usize,
666    /// Each runtime counted, the living first; at most [`SUMMARY_LIST`].
667    pub runtimes: Vec<RuntimeSummary>,
668    /// Runtimes past the list, counted.
669    pub runtimes_not_listed: usize,
670    /// Review requests nobody has answered; at most [`SUMMARY_LIST`].
671    pub open_reviews: Vec<ReviewBrief>,
672    /// Open reviews past the list, counted.
673    pub open_reviews_not_listed: usize,
674    /// Handovers nobody has consumed; at most [`SUMMARY_LIST`].
675    pub untaken_handovers: Vec<HandoverBrief>,
676    /// Untaken handovers past the list, counted.
677    pub untaken_handovers_not_listed: usize,
678}
679
680fn rank(liveness: StreamLiveness) -> u8 {
681    match liveness {
682        StreamLiveness::Own => 0,
683        StreamLiveness::Live => 1,
684        StreamLiveness::Expired => 2,
685    }
686}
687
688/// The summary of `state` (already narrowed), with each runtime's liveness and the
689/// milliseconds since its beat rose from `streams`. A runtime that only `streams` names is
690/// listed with no sessions; one the state names and `streams` does not is `expired`.
691///
692/// ```
693/// use std::sync::Arc;
694/// use majordomus_cli::mesh::identity::NodeIdentity;
695/// use majordomus_cli::mesh::journal::{ClaimMode, EventBody, Journal, SessionInfo, StreamLiveness};
696/// use majordomus_cli::mesh::state::fold;
697///
698/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
699///     "repo".into(), None).unwrap();
700/// j.append_own(EventBody::SessionOpened { info: SessionInfo::named("s1", "claude-code") })
701///     .unwrap();
702/// for (claim, path) in [("c1", "apps/a"), ("c2", "apps/b")] {
703///     j.append_own(EventBody::ClaimAcquired { claim: claim.into(), session: "s1".into(),
704///         scope: vec![path.into()], intent: Some("work".into()), mode: ClaimMode::Exclusive,
705///         issue: Some("#1".into()) }).unwrap();
706/// }
707/// j.append_own(EventBody::ClaimReleased { claim: "c1".into() }).unwrap();
708/// let state = fold(&j.events(), &|_| StreamLiveness::Own);
709/// use majordomus_cli::mesh::page::summarize;
710///
711/// let runtime = state.sessions[0].runtime.clone();
712/// let summary = summarize(&state, &[(runtime, StreamLiveness::Own, Some(12))]);
713/// assert_eq!(summary.runtimes[0].liveness, StreamLiveness::Own);
714/// assert_eq!(summary.runtimes[0].last_beat_ms, Some(12));
715/// ```
716pub fn summarize(
717    state: &CooperationState,
718    streams: &[(String, StreamLiveness, Option<u64>)],
719) -> Summary {
720    let mut runtimes: BTreeMap<String, RuntimeSummary> = BTreeMap::new();
721    let entry = |key: &str| -> RuntimeSummary {
722        RuntimeSummary {
723            runtime: key.to_string(),
724            liveness: StreamLiveness::Expired,
725            last_beat_ms: None,
726            sessions: SessionCounts::default(),
727            claims: ClaimCounts::default(),
728            newest_claim: None,
729        }
730    };
731    for (runtime, liveness, age) in streams {
732        let node = runtimes
733            .entry(runtime.clone())
734            .or_insert_with(|| entry(runtime));
735        if rank(*liveness) < rank(node.liveness) {
736            node.liveness = *liveness;
737        }
738        if let Some(age) = age {
739            node.last_beat_ms = Some(node.last_beat_ms.map_or(*age, |b| b.min(*age)));
740        }
741    }
742    let mut summary = Summary {
743        digest: state.digest.clone(),
744        overlaps: state.overlaps.len(),
745        ..Summary::default()
746    };
747    for session in &state.sessions {
748        summary.sessions.add(session.state);
749        runtimes
750            .entry(session.runtime.clone())
751            .or_insert_with(|| entry(&session.runtime))
752            .sessions
753            .add(session.state);
754    }
755    for claim in &state.claims {
756        summary.claims.add(&claim.state);
757        let node = runtimes
758            .entry(claim.runtime.clone())
759            .or_insert_with(|| entry(&claim.runtime));
760        node.claims.add(&claim.state);
761        let newer = node
762            .newest_claim
763            .as_ref()
764            .is_none_or(|b| claim.acquired_lamport > b.acquired_lamport);
765        if claim.state.is_live() && newer {
766            node.newest_claim = Some(ClaimBrief {
767                key: claim.key.clone(),
768                session: claim.session.clone(),
769                issue: claim.issue.clone(),
770                intent: claim.intent.as_deref().map(cut),
771                paths: claim.scope.len(),
772                mode: claim.mode,
773                acquired_lamport: claim.acquired_lamport,
774            });
775        }
776    }
777    // the living first, then by key: the one order every surface shows
778    let ranked: BTreeMap<(u8, String), RuntimeSummary> = runtimes
779        .into_values()
780        .map(|r| ((rank(r.liveness), r.runtime.clone()), r))
781        .collect();
782    let all: Vec<RuntimeSummary> = ranked.into_values().collect();
783    summary.runtimes_not_listed = all.len().saturating_sub(SUMMARY_LIST);
784    summary.runtimes = all.into_iter().take(SUMMARY_LIST).collect();
785
786    let open: Vec<ReviewBrief> = state
787        .reviews
788        .iter()
789        .filter(|r| r.state == ReviewState::Open)
790        .map(|r| ReviewBrief {
791            key: r.key.clone(),
792            subject: cut(&r.subject),
793            issue: r.issue.clone(),
794            reviewer: r.reviewer.clone(),
795        })
796        .collect();
797    summary.open_reviews_not_listed = open.len().saturating_sub(SUMMARY_LIST);
798    summary.open_reviews = open.into_iter().take(SUMMARY_LIST).collect();
799
800    let untaken: Vec<HandoverBrief> = state
801        .handovers
802        .iter()
803        .filter(|h| h.consumed_by.is_empty())
804        .map(|h| HandoverBrief {
805            id: h.id.clone(),
806            runtime: h.runtime.clone(),
807            branch: h.handover.branch.clone(),
808            issue: h.handover.issue.clone(),
809            published_lamport: h.published_lamport,
810        })
811        .collect();
812    summary.untaken_handovers_not_listed = untaken.len().saturating_sub(SUMMARY_LIST);
813    summary.untaken_handovers = untaken.into_iter().take(SUMMARY_LIST).collect();
814    summary
815}
816
817#[cfg(test)]
818mod tests {
819    use std::sync::Arc;
820
821    use super::*;
822    use crate::mesh::identity::NodeIdentity;
823    use crate::mesh::journal::{
824        ClaimMode, EventBody, HandoverBody, Journal, SessionInfo, StreamLiveness,
825    };
826    use crate::mesh::state::fold;
827
828    fn journal() -> Journal {
829        Journal::open(
830            Arc::new(NodeIdentity::ephemeral().unwrap()),
831            "0000000000000001",
832            "repo".into(),
833            None,
834        )
835        .unwrap()
836    }
837
838    fn claim(j: &Journal, claim: &str, session: &str, intent: &str) {
839        j.append_own(EventBody::ClaimAcquired {
840            claim: claim.into(),
841            session: session.into(),
842            scope: vec![format!("apps/{claim}")],
843            intent: Some(intent.into()),
844            mode: ClaimMode::Exclusive,
845            issue: Some(format!("#{claim}")),
846        })
847        .unwrap();
848    }
849
850    /// A mesh with history: sessions that came and went, claims taken and released, a
851    /// review answered and one open, a handover taken and one not.
852    fn history() -> (Journal, CooperationState) {
853        let j = journal();
854        for n in 0..40 {
855            let session = format!("s{n}");
856            j.append_own(EventBody::SessionOpened {
857                info: SessionInfo::named(&session, "claude-code"),
858            })
859            .unwrap();
860            claim(&j, &format!("c{n}"), &session, &"a long intent ".repeat(30));
861            if n < 38 {
862                j.append_own(EventBody::ClaimReleased {
863                    claim: format!("c{n}"),
864                })
865                .unwrap();
866                j.append_own(EventBody::SessionClosed {
867                    session: session.clone(),
868                })
869                .unwrap();
870            }
871        }
872        for (review, answered) in [("r1", true), ("r2", false)] {
873            j.append_own(EventBody::ReviewRequested {
874                review: review.into(),
875                session: "s39".into(),
876                subject: format!("feature/{review}"),
877                scope: vec![],
878                issue: None,
879                reviewer: None,
880                body: None,
881            })
882            .unwrap();
883            if answered {
884                let key = fold(&j.events(), &|_| StreamLiveness::Own)
885                    .reviews
886                    .iter()
887                    .find(|r| r.subject.ends_with(review))
888                    .unwrap()
889                    .key
890                    .clone();
891                j.append_own(EventBody::ReviewAnswered {
892                    request: key,
893                    session: "s38".into(),
894                    verdict: "approved".into(),
895                    note: None,
896                })
897                .unwrap();
898            }
899        }
900        for (n, taken) in [(1, true), (2, false)] {
901            let body = format!("# Objective\nhandover {n}\n");
902            let handover = HandoverBody {
903                id: HandoverBody::digest_of(&body),
904                task: None,
905                issue: Some(format!("I{n}")),
906                milestone: None,
907                branch: Some(format!("feature/h{n}")),
908                head: None,
909                created_at: None,
910                name: None,
911                body,
912            };
913            let id = handover.id.clone();
914            j.append_own(EventBody::HandoverPublished { handover })
915                .unwrap();
916            if taken {
917                j.append_own(EventBody::HandoverConsumed {
918                    handover: id,
919                    session: "s39".into(),
920                })
921                .unwrap();
922            }
923        }
924        let state = fold(&j.events(), &|_| StreamLiveness::Own);
925        (j, state)
926    }
927
928    fn streams(state: &CooperationState) -> Vec<(String, StreamLiveness, Option<u64>)> {
929        vec![(
930            state.sessions[0].runtime.clone(),
931            StreamLiveness::Own,
932            Some(5),
933        )]
934    }
935
936    /// Claims with no session: an advisory one, an exclusive one it overlaps, and an
937    /// exclusive one the second beats to its scope; and a review with a scope.
938    fn overlapping() -> CooperationState {
939        let j = journal();
940        for (claim, session, path, mode) in [
941            ("c1", "s1", "apps", ClaimMode::Advisory),
942            ("c2", "s2", "apps/x", ClaimMode::Exclusive),
943            ("c3", "s3", "apps/x/y", ClaimMode::Exclusive),
944        ] {
945            j.append_own(EventBody::ClaimAcquired {
946                claim: claim.into(),
947                session: session.into(),
948                scope: vec![path.into()],
949                intent: None,
950                mode,
951                issue: None,
952            })
953            .unwrap();
954        }
955        j.append_own(EventBody::ReviewRequested {
956            review: "r1".into(),
957            session: "s1".into(),
958            subject: "feature/r1".into(),
959            scope: vec!["apps/x".into()],
960            issue: None,
961            reviewer: None,
962            body: None,
963        })
964        .unwrap();
965        fold(&j.events(), &|_| StreamLiveness::Own)
966    }
967
968    #[test]
969    fn a_conflict_and_a_runtime_known_only_by_its_claims_are_counted() {
970        let state = overlapping();
971        let summary = summarize(&state, &[]);
972        assert_eq!(
973            summary.runtimes.len(),
974            1,
975            "a runtime heard of only through claims"
976        );
977        assert_eq!(summary.runtimes[0].claims.held, 2);
978        assert_eq!(summary.claims.conflicted, 1);
979        assert_eq!(summary.runtimes[0].claims.conflicted, 1);
980        assert!(
981            summary.overlaps > 0,
982            "the advisory claim meets the exclusive ones"
983        );
984    }
985
986    #[test]
987    fn an_expired_stream_counts_its_sessions_and_claims_as_expired() {
988        let (j, _) = history();
989        let gone = fold(&j.events(), &|_| StreamLiveness::Expired);
990        let summary = summarize(&gone, &[]);
991        assert_eq!(summary.sessions.expired, 2, "the two sessions still open");
992        assert_eq!(summary.claims.expired, 2, "the two claims still held");
993        assert_eq!(summary.claims.released, 38, "a release stays a release");
994    }
995
996    #[test]
997    fn overlaps_follow_the_matched_claims_and_a_review_matches_by_its_scope() {
998        let state = overlapping();
999        let matched = narrow(
1000            &state,
1001            &Query {
1002                matching: Some("apps/x".into()),
1003                ..Query::default()
1004            },
1005        );
1006        assert_eq!(
1007            matched.reviews.len(),
1008            1,
1009            "the review's scope names the path"
1010        );
1011        assert!(!matched.overlaps.is_empty());
1012        assert!(matched.overlaps.iter().all(|o| matched
1013            .claims
1014            .iter()
1015            .any(|c| c.key == o.first || c.key == o.second)));
1016        let overlaps = narrow(
1017            &state,
1018            &Query {
1019                kind: Some(Kind::Overlaps),
1020                ..Query::default()
1021            },
1022        );
1023        assert_eq!(overlaps.overlaps.len(), state.overlaps.len());
1024        assert!(overlaps.claims.is_empty() && overlaps.reviews.is_empty());
1025        // after the first stamp: every review whose session moved since is kept
1026        let (_j, history) = history();
1027        let after = narrow(
1028            &history,
1029            &Query {
1030                after: Some(0),
1031                ..Query::default()
1032            },
1033        );
1034        assert_eq!(after.reviews.len(), history.reviews.len());
1035    }
1036
1037    #[test]
1038    fn the_summary_counts_the_history_and_lists_only_what_is_live_or_waiting() {
1039        let (_j, state) = history();
1040        let summary = summarize(&state, &streams(&state));
1041        assert_eq!(
1042            summary.digest, state.digest,
1043            "the whole fold's digest, not a page's"
1044        );
1045        assert_eq!(
1046            summary.sessions,
1047            SessionCounts {
1048                active: 2,
1049                closed: 38,
1050                expired: 0
1051            }
1052        );
1053        assert_eq!((summary.claims.held, summary.claims.released), (2, 38));
1054        assert_eq!(summary.runtimes.len(), 1);
1055        let runtime = &summary.runtimes[0];
1056        assert_eq!(runtime.liveness, StreamLiveness::Own);
1057        assert_eq!(runtime.last_beat_ms, Some(5));
1058        let newest = runtime.newest_claim.as_ref().expect("a live claim");
1059        assert_eq!(
1060            newest.issue.as_deref(),
1061            Some("#c39"),
1062            "the newest live claim"
1063        );
1064        assert!(
1065            newest.intent.as_ref().unwrap().ends_with('…'),
1066            "the intent is cut"
1067        );
1068        assert_eq!(summary.open_reviews.len(), 1);
1069        assert_eq!(summary.open_reviews[0].subject, "feature/r2");
1070        assert_eq!(summary.untaken_handovers.len(), 1);
1071        assert_eq!(
1072            summary.untaken_handovers[0].branch.as_deref(),
1073            Some("feature/h2")
1074        );
1075    }
1076
1077    #[test]
1078    fn the_summary_stays_small_however_long_the_history() {
1079        let (_j, state) = history();
1080        let full = serde_json::to_string(&state).unwrap().len();
1081        let summary = serde_json::to_string(&summarize(&state, &streams(&state)))
1082            .unwrap()
1083            .len();
1084        assert!(summary < 16 * 1024, "{summary} bytes");
1085        assert!(
1086            summary * 10 < full,
1087            "summary {summary} against the full {full}"
1088        );
1089    }
1090
1091    #[test]
1092    fn a_list_past_its_bound_is_counted_not_dropped() {
1093        let (_j, mut state) = history();
1094        let template = state.reviews[0].clone();
1095        state.reviews = (0..SUMMARY_LIST + 3)
1096            .map(|n| {
1097                let mut r = template.clone();
1098                r.key = format!("{}-{n}", template.key);
1099                r.state = ReviewState::Open;
1100                r
1101            })
1102            .collect();
1103        let summary = summarize(&state, &[]);
1104        assert_eq!(summary.open_reviews.len(), SUMMARY_LIST);
1105        assert_eq!(summary.open_reviews_not_listed, 3);
1106    }
1107
1108    #[test]
1109    fn many_runtimes_are_listed_living_first_and_the_rest_counted() {
1110        let (_j, state) = history();
1111        let own = state.sessions[0].runtime.clone();
1112        let mut streams: Vec<_> = (0..SUMMARY_LIST + 2)
1113            .map(|n| (format!("{n:0>49}"), StreamLiveness::Expired, None))
1114            .collect();
1115        streams.push((own.clone(), StreamLiveness::Live, Some(9)));
1116        streams.push((own.clone(), StreamLiveness::Own, Some(3)));
1117        let summary = summarize(&state, &streams);
1118        assert_eq!(summary.runtimes[0].runtime, own, "the living first");
1119        assert_eq!(
1120            summary.runtimes[0].liveness,
1121            StreamLiveness::Own,
1122            "the best verdict wins"
1123        );
1124        assert_eq!(
1125            summary.runtimes[0].last_beat_ms,
1126            Some(3),
1127            "and the freshest beat"
1128        );
1129        assert_eq!(summary.runtimes.len(), SUMMARY_LIST);
1130        assert_eq!(summary.runtimes_not_listed, 3);
1131    }
1132
1133    #[test]
1134    fn live_claims_only_drops_the_ended_ones_and_their_overlaps_follow() {
1135        let (_j, state) = history();
1136        let live = narrow(
1137            &state,
1138            &Query {
1139                claims: Some(ClaimFilter::Live),
1140                ..Query::default()
1141            },
1142        );
1143        assert_eq!(live.claims.len(), 2);
1144        assert!(live.claims.iter().all(|c| c.state.is_live()));
1145        assert_eq!(
1146            live.sessions.len(),
1147            state.sessions.len(),
1148            "sessions are not claims"
1149        );
1150        assert_eq!(live.digest, state.digest);
1151        assert!(live.overlaps.iter().all(|o| live
1152            .claims
1153            .iter()
1154            .any(|c| c.key == o.first || c.key == o.second)));
1155    }
1156
1157    #[test]
1158    fn a_session_is_asked_for_by_its_key_or_its_bare_id() {
1159        let (_j, state) = history();
1160        let by_id = narrow(
1161            &state,
1162            &Query {
1163                session: Some("s39".into()),
1164                ..Query::default()
1165            },
1166        );
1167        assert_eq!(by_id.sessions.len(), 1);
1168        assert_eq!(by_id.claims.len(), 1);
1169        assert_eq!(by_id.reviews.len(), 2, "both requests it opened");
1170        assert_eq!(by_id.handovers.len(), 1, "the one it took");
1171        let key = by_id.sessions[0].key.clone();
1172        let by_key = narrow(
1173            &state,
1174            &Query {
1175                session: Some(key),
1176                ..Query::default()
1177            },
1178        );
1179        assert_eq!(by_key, by_id);
1180        let nobody = narrow(
1181            &state,
1182            &Query {
1183                session: Some("s99".into()),
1184                ..Query::default()
1185            },
1186        );
1187        assert!(nobody.sessions.is_empty() && nobody.claims.is_empty());
1188    }
1189
1190    #[test]
1191    fn a_runtime_is_asked_for_by_its_key_or_its_node() {
1192        let (_j, state) = history();
1193        let runtime = state.sessions[0].runtime.clone();
1194        let node = runtime.split('-').next().unwrap().to_string();
1195        let mine = narrow(
1196            &state,
1197            &Query {
1198                runtime: Some(runtime),
1199                ..Query::default()
1200            },
1201        );
1202        assert_eq!(mine.sessions.len(), state.sessions.len());
1203        let machine = narrow(
1204            &state,
1205            &Query {
1206                runtime: Some(node),
1207                ..Query::default()
1208            },
1209        );
1210        assert_eq!(machine, mine);
1211        let elsewhere = narrow(
1212            &state,
1213            &Query {
1214                runtime: Some("f".repeat(49)),
1215                ..Query::default()
1216            },
1217        );
1218        assert!(elsewhere.sessions.is_empty() && elsewhere.reviews.is_empty());
1219        assert!(elsewhere.handovers.is_empty());
1220    }
1221
1222    #[test]
1223    fn after_keeps_only_what_moved_since_the_stamp() {
1224        let (_j, state) = history();
1225        let stamp = state
1226            .claims
1227            .iter()
1228            .map(|c| c.acquired_lamport)
1229            .max()
1230            .unwrap();
1231        let later = narrow(
1232            &state,
1233            &Query {
1234                after: Some(stamp - 1),
1235                ..Query::default()
1236            },
1237        );
1238        assert_eq!(later.claims.len(), 1, "only the newest acquisition");
1239        assert!(later.sessions.iter().all(|s| s.updated_lamport > stamp - 1));
1240        assert!(later
1241            .reviews
1242            .iter()
1243            .all(|r| later.sessions.iter().any(|s| s.key == r.session)));
1244        let nothing = narrow(
1245            &state,
1246            &Query {
1247                after: Some(u64::MAX),
1248                ..Query::default()
1249            },
1250        );
1251        assert!(nothing.sessions.is_empty() && nothing.claims.is_empty());
1252        assert!(nothing.handovers.is_empty() && nothing.reviews.is_empty());
1253    }
1254
1255    #[test]
1256    fn a_kind_answers_one_collection_and_a_limit_the_newest_of_each() {
1257        let (_j, state) = history();
1258        let claims = narrow(
1259            &state,
1260            &Query {
1261                kind: Some(Kind::Claims),
1262                ..Query::default()
1263            },
1264        );
1265        assert_eq!(claims.claims.len(), state.claims.len());
1266        assert!(claims.sessions.is_empty() && claims.reviews.is_empty());
1267        assert!(claims.handovers.is_empty() && claims.overlaps.is_empty());
1268        assert_eq!(claims.digest, state.digest);
1269
1270        let three = narrow(
1271            &state,
1272            &Query {
1273                limit: Some(3),
1274                ..Query::default()
1275            },
1276        );
1277        assert_eq!(three.claims.len(), 3);
1278        let newest = state
1279            .claims
1280            .iter()
1281            .map(|c| c.acquired_lamport)
1282            .max()
1283            .unwrap();
1284        assert!(three.claims.iter().any(|c| c.acquired_lamport == newest));
1285        let in_fold: Vec<_> = state
1286            .claims
1287            .iter()
1288            .filter(|c| three.claims.contains(c))
1289            .cloned()
1290            .collect();
1291        assert_eq!(three.claims, in_fold, "kept in the fold's own order");
1292        assert!(three.sessions.len() <= 3 && three.reviews.len() <= 3);
1293        let none = narrow(
1294            &state,
1295            &Query {
1296                limit: Some(0),
1297                ..Query::default()
1298            },
1299        );
1300        assert!(none.claims.is_empty() && none.sessions.is_empty());
1301        let all = narrow(
1302            &state,
1303            &Query {
1304                limit: Some(10_000),
1305                ..Query::default()
1306            },
1307        );
1308        assert_eq!(all, state);
1309    }
1310
1311    #[test]
1312    fn a_limit_keeps_ties_in_order_until_it_is_reached() {
1313        let items = vec![("a", 5), ("b", 7), ("c", 7), ("d", 1)];
1314        assert_eq!(newest(items.clone(), Some(1), |i| i.1), [("b", 7)]);
1315        assert_eq!(
1316            newest(items.clone(), Some(2), |i| i.1),
1317            [("b", 7), ("c", 7)]
1318        );
1319        assert_eq!(
1320            newest(items.clone(), Some(3), |i| i.1),
1321            [("a", 5), ("b", 7), ("c", 7)]
1322        );
1323        assert_eq!(newest(items.clone(), None, |i| i.1), items);
1324    }
1325
1326    #[test]
1327    fn open_keeps_only_what_is_still_open_in_every_collection() {
1328        let (_j, state) = history();
1329        let open = narrow(
1330            &state,
1331            &Query {
1332                open: Some(true),
1333                ..Query::default()
1334            },
1335        );
1336        assert_eq!(open.sessions.len(), 2);
1337        assert!(open
1338            .sessions
1339            .iter()
1340            .all(|s| s.state == SessionState::Active));
1341        assert_eq!(open.claims.len(), 2);
1342        assert_eq!(open.reviews.len(), 1);
1343        assert_eq!(open.reviews[0].subject, "feature/r2");
1344        assert_eq!(open.handovers.len(), 1);
1345        assert_eq!(
1346            open.handovers[0].handover.branch.as_deref(),
1347            Some("feature/h2")
1348        );
1349        let closed = narrow(
1350            &state,
1351            &Query {
1352                open: Some(false),
1353                ..Query::default()
1354            },
1355        );
1356        assert_eq!(closed, state, "open=false asks for nothing narrower");
1357    }
1358
1359    #[test]
1360    fn matching_finds_a_path_a_branch_an_issue_or_a_subject_ignoring_case() {
1361        let (_j, state) = history();
1362        let path = narrow(
1363            &state,
1364            &Query {
1365                matching: Some("APPS/C7".into()),
1366                ..Query::default()
1367            },
1368        );
1369        assert_eq!(path.claims.len(), 1, "apps/c7 and nothing else");
1370        assert_eq!(path.claims[0].scope, ["apps/c7"]);
1371        assert!(path.handovers.is_empty() && path.reviews.is_empty());
1372        let issue = narrow(
1373            &state,
1374            &Query {
1375                matching: Some("#c39".into()),
1376                ..Query::default()
1377            },
1378        );
1379        assert_eq!(issue.claims.len(), 1);
1380        let branch = narrow(
1381            &state,
1382            &Query {
1383                matching: Some("feature/h2".into()),
1384                ..Query::default()
1385            },
1386        );
1387        assert_eq!(branch.handovers.len(), 1);
1388        let subject = narrow(
1389            &state,
1390            &Query {
1391                matching: Some("feature/r1".into()),
1392                ..Query::default()
1393            },
1394        );
1395        assert_eq!(subject.reviews.len(), 1);
1396        let session = narrow(
1397            &state,
1398            &Query {
1399                matching: Some("/s39".into()),
1400                ..Query::default()
1401            },
1402        );
1403        assert_eq!(session.sessions.len(), 1, "a session's key is text too");
1404        let nothing = narrow(
1405            &state,
1406            &Query {
1407                matching: Some("no such".into()),
1408                ..Query::default()
1409            },
1410        );
1411        assert!(nothing.claims.is_empty() && nothing.sessions.is_empty());
1412        assert_eq!(nothing.digest, state.digest);
1413    }
1414
1415    #[test]
1416    fn twenty_thousand_events_summarise_under_sixteen_kilobytes() {
1417        // the acceptance of I2414: a journal of 20 000 events, the default answer under 16 kB
1418        let j = journal();
1419        let mut events = 0;
1420        let mut n = 0;
1421        while events < 20_000 {
1422            let session = format!("s{n}");
1423            j.append_own(EventBody::SessionOpened {
1424                info: SessionInfo::named(&session, "claude-code"),
1425            })
1426            .unwrap();
1427            claim(&j, &format!("c{n}"), &session, &"an intent ".repeat(40));
1428            j.append_own(EventBody::ClaimReleased {
1429                claim: format!("c{n}"),
1430            })
1431            .unwrap();
1432            j.append_own(EventBody::SessionClosed { session }).unwrap();
1433            events += 4;
1434            n += 1;
1435        }
1436        let state = fold(&j.events(), &|_| StreamLiveness::Own);
1437        assert_eq!(state.claims.len(), n);
1438        let summary = serde_json::to_string(&summarize(&state, &streams(&state))).unwrap();
1439        assert!(summary.len() < 16 * 1024, "{} bytes", summary.len());
1440        let full = serde_json::to_string(&state).unwrap();
1441        assert!(
1442            full.len() > 1024 * 1024,
1443            "the whole is what the summary spares: {}",
1444            full.len()
1445        );
1446    }
1447
1448    #[test]
1449    fn a_text_is_cut_on_a_character_and_says_so() {
1450        assert_eq!(cut("short"), "short");
1451        // a two-byte character, written as its escape: the source stays English-only
1452        let long = "\u{10d}".repeat(SUMMARY_TEXT + 1);
1453        let cut = cut(&long);
1454        assert!(cut.ends_with('…'));
1455        assert_eq!(cut.chars().count(), SUMMARY_TEXT + 1);
1456    }
1457
1458    #[test]
1459    fn the_view_defaults_to_the_summary() {
1460        assert_eq!(Query::default().view(), View::Summary);
1461        let full: Query = serde_json::from_str(r#"{"view":"full","claims":"live"}"#).unwrap();
1462        assert_eq!(full.view(), View::Full);
1463        assert!(serde_json::from_str::<Query>(r#"{"bogus":1}"#).is_err());
1464    }
1465}