1use std::collections::BTreeMap;
14use std::net::Ipv4Addr;
15
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18
19use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
20use crate::capability::handler::{CapabilityError, Context};
21use crate::capability::model::{
22 BenchmarkPolicy, CachePolicy, CapabilityKind, CliExposure, Exposure, Stability, WaiverReason,
23};
24use crate::capability::module::ModuleDescriptor;
25use crate::mesh::address::interfaces;
26use crate::mesh::cooperation::{
27 Cooperation, CooperationError, CooperationStatus, MeshVerifyReport, PeerView, RefusedView,
28 Written,
29};
30use crate::mesh::firewall::{FirewallApplyReport, FirewallReport};
31use crate::mesh::journal::{ClaimMode, MeshEvent, SessionInfo, StreamId, StreamLiveness};
32use crate::mesh::link::{LinkReply, RefusalCode, Signed, HELLO_PATH, SYNC_PATH};
33use crate::mesh::page::{self, ClaimBrief, Query, Summary, View};
34use crate::mesh::rendezvous::REGISTER_PATH;
35use crate::mesh::state::{ClaimView, CooperationState, HandoverView, SessionState};
36use crate::mesh::{
37 default_identity_path, MeshConfig, MeshDoctorReport, MeshStatus, NodeIdentity, NodeRecord,
38 PublicIdentity, RegisterAnswer,
39};
40use crate::{capability, module};
41
42use super::{get, mcp, post, Empty};
43
44pub const MESH_URI: &str = "majordomus://mesh";
46
47fn mesh_status(ctx: &Context, _: Empty) -> Result<MeshStatus, CapabilityError> {
50 Ok(ctx.mesh.status())
51}
52
53#[derive(Debug, Serialize, Deserialize, JsonSchema)]
56pub struct NodeList {
58 pub count: usize,
60 pub nodes: Vec<NodeRecord>,
62}
63
64fn mesh_nodes(ctx: &Context, _: Empty) -> Result<NodeList, CapabilityError> {
65 let nodes = ctx.mesh.nodes();
66 Ok(NodeList {
67 count: nodes.len(),
68 nodes,
69 })
70}
71
72#[derive(Debug, Serialize, Deserialize, JsonSchema)]
75pub struct MeshIdentityReport {
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub path: Option<String>,
80 pub present: bool,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub identity: Option<PublicIdentity>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub error: Option<String>,
89}
90
91fn mesh_identity(_: &Context, _: Empty) -> Result<MeshIdentityReport, CapabilityError> {
92 let Some(path) = default_identity_path() else {
93 return Ok(MeshIdentityReport {
94 path: None,
95 present: false,
96 identity: None,
97 error: Some("no HOME and no XDG_STATE_HOME: nowhere to keep a node identity".into()),
98 });
99 };
100 if !path.is_file() {
101 return Ok(MeshIdentityReport {
102 path: Some(path.display().to_string()),
103 present: false,
104 identity: None,
105 error: None,
106 });
107 }
108 match NodeIdentity::load_or_create(&path) {
109 Ok(identity) => Ok(MeshIdentityReport {
110 path: Some(path.display().to_string()),
111 present: true,
112 identity: Some(identity.public),
113 error: None,
114 }),
115 Err(e) => Ok(MeshIdentityReport {
116 path: Some(path.display().to_string()),
117 present: true,
118 identity: None,
119 error: Some(e.to_string()),
120 }),
121 }
122}
123
124fn mesh_doctor(ctx: &Context, _: Empty) -> Result<MeshDoctorReport, CapabilityError> {
127 let root = std::path::Path::new(&ctx.index.repository.root);
128 let runtime = ctx.mesh.decided().then(|| ctx.mesh.status());
131 let resolved = resolved_declaration(ctx);
132 let source = crate::mesh::doctor::declaration_source(&resolved);
133 let mut report =
134 crate::mesh::doctor::doctor_at(resolved.declaration, Some(root), runtime.as_ref());
135 if let Some(check) = source {
136 report.checks.push(check);
137 }
138 if let Some(cooperation) = ctx.mesh.cooperation() {
140 report.checks.push(crate::mesh::doctor::journal_check(
141 &cooperation.journal().tallies(),
142 ));
143 }
144 report.ok = report.checks.iter().all(|c| c.ok);
146 Ok(report)
147}
148
149#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
152#[serde(deny_unknown_fields)]
153pub struct FirewallInput {
155 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub port: Option<u16>,
160}
161
162impl BenchmarkCases for FirewallInput {
163 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
164 vec![NamedCase::new("declared", FirewallInput { port: None })]
165 }
166}
167
168fn firewall_report(ctx: &Context, input: &FirewallInput) -> FirewallReport {
170 let config = declaration(ctx).and_then(|d| d.ok());
171 let local: Vec<Ipv4Addr> = interfaces().into_iter().map(|(_, ip)| ip).collect();
172 crate::mesh::firewall::report(config.as_ref(), &local, input.port)
173}
174
175fn mesh_firewall(ctx: &Context, input: FirewallInput) -> Result<FirewallReport, CapabilityError> {
176 Ok(firewall_report(ctx, &input))
177}
178
179fn mesh_firewall_apply(
180 ctx: &Context,
181 input: FirewallInput,
182) -> Result<FirewallApplyReport, CapabilityError> {
183 Ok(crate::mesh::firewall::apply(firewall_report(ctx, &input)))
184}
185
186pub(crate) fn retrust(ctx: &Context) -> Vec<String> {
191 let (Some(cooperation), Some(Ok(config))) = (ctx.mesh.cooperation(), declaration(ctx)) else {
192 return Vec::new();
193 };
194 cooperation.retrust(config.trust)
195}
196
197pub fn declaration(ctx: &Context) -> Option<Result<MeshConfig, crate::mesh::MeshError>> {
202 resolved_declaration(ctx).declaration
203}
204
205pub(crate) fn resolved_declaration(ctx: &Context) -> crate::mesh::root::Resolved {
207 let root = std::path::Path::new(&ctx.index.repository.root);
208 let working = ctx
209 .index
210 .objects
211 .iter()
212 .find(|o| o.kind == crate::mesh::KIND);
213 crate::mesh::root::resolve(root, working)
214}
215
216#[derive(Debug, Serialize, Deserialize, JsonSchema)]
219#[serde(deny_unknown_fields)]
220pub struct RegisterInput {
222 pub envelope: serde_json::Value,
225}
226
227impl BenchmarkCases for RegisterInput {
228 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
229 vec![NamedCase::new(
232 "refused",
233 RegisterInput {
234 envelope: serde_json::json!({"v": 0}),
235 },
236 )]
237 }
238}
239
240fn mesh_register(ctx: &Context, input: RegisterInput) -> Result<RegisterAnswer, CapabilityError> {
241 Ok(ctx.mesh.register(&input.envelope, "http"))
242}
243
244fn cooperation(ctx: &Context) -> Result<std::sync::Arc<Cooperation>, CapabilityError> {
247 ctx.mesh.cooperation().ok_or_else(|| {
248 CapabilityError::Refused(format!(
249 "not_active: {}",
250 ctx.mesh
251 .cooperation_status()
252 .reason
253 .unwrap_or_else(|| "cooperation is not running in this process".into())
254 ))
255 })
256}
257
258fn map_error(e: CooperationError) -> CapabilityError {
259 match e {
260 CooperationError::Conflict(_) => CapabilityError::Refused(format!("claim_conflict: {e}")),
261 CooperationError::NotOwn(m) => CapabilityError::Refused(format!("not_own: {m}")),
262 CooperationError::NotFound(m) => CapabilityError::NotFound(m),
263 CooperationError::FeatureUnsupported(m) => {
264 CapabilityError::Refused(format!("{}: {m}", RefusalCode::FeatureUnsupported.as_str()))
265 }
266 CooperationError::Invalid(m) => CapabilityError::InvalidInput(m),
267 }
268}
269
270fn session_of(ctx: &Context, given: Option<String>) -> Result<String, CapabilityError> {
273 if let Some(session) = given.filter(|s| !s.trim().is_empty()) {
274 return Ok(session);
275 }
276 ctx.caller
277 .as_ref()
278 .map(|peer| format!("board-{}", peer.as_str()))
279 .ok_or_else(|| {
280 CapabilityError::InvalidInput(
281 "name the session: over the command line or plain HTTP there is no calling session"
282 .into(),
283 )
284 })
285}
286
287fn mesh_cooperation(ctx: &Context, _: Empty) -> Result<CooperationStatus, CapabilityError> {
290 Ok(ctx.mesh.cooperation_status())
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
297pub struct SessionNode {
298 pub key: String,
300 pub info: SessionInfo,
302 pub state: SessionState,
304 pub claims: Vec<ClaimView>,
306 pub claims_held: usize,
308 pub reviews: Vec<String>,
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
314pub struct RuntimeNode {
315 pub runtime: String,
317 pub this_runtime: bool,
319 pub liveness: StreamLiveness,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub last_beat_ms: Option<u64>,
324 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub link: Option<PeerView>,
327 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub newest_claim: Option<ClaimBrief>,
330 pub sessions: Vec<SessionNode>,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
336pub struct MachineNode {
337 pub node: String,
339 pub name: String,
341 pub local: bool,
343 pub runtimes: Vec<RuntimeNode>,
345}
346
347impl crate::order::Ordered for MachineNode {
350 fn order_key(&self) -> crate::order::OrderKey<'_> {
351 crate::order::OrderKey::plain(&self.name, &self.node).ranked(i64::from(!self.local))
352 }
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
358pub struct PeerTree {
359 pub active: bool,
361 pub view: View,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub reason: Option<String>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub runtime: Option<String>,
369 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub repository: Option<String>,
372 pub machines: Vec<MachineNode>,
374 pub refused: Vec<RefusedView>,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub digest: Option<String>,
379}
380
381fn rank(l: StreamLiveness) -> u8 {
382 match l {
383 StreamLiveness::Own => 0,
384 StreamLiveness::Live => 1,
385 StreamLiveness::Expired => 2,
386 }
387}
388
389pub fn peer_tree(c: &Cooperation, query: &Query) -> PeerTree {
394 let status = c.status();
395 let whole = c.state();
396 let state = page::narrow(&whole, query);
397 let summary = query.view() == View::Summary;
398 let links: BTreeMap<String, PeerView> = status
399 .peers
400 .iter()
401 .map(|p| (p.runtime.clone(), p.clone()))
402 .collect();
403 let own = status.runtime.clone().unwrap_or_default();
404 let own_node = own.get(..32).unwrap_or_default().to_string();
405
406 let mut runtimes: BTreeMap<String, RuntimeNode> = BTreeMap::new();
408 let mut touch = |key: &str, liveness: StreamLiveness, age: Option<u64>| {
409 let node = runtimes
410 .entry(key.to_string())
411 .or_insert_with(|| RuntimeNode {
412 runtime: key.to_string(),
413 this_runtime: key == own,
414 liveness,
415 last_beat_ms: age,
416 link: None,
417 newest_claim: None,
418 sessions: Vec::new(),
419 });
420 if rank(liveness) < rank(node.liveness) {
421 node.liveness = liveness;
422 }
423 if let Some(a) = age {
424 node.last_beat_ms = Some(node.last_beat_ms.map_or(a, |b| b.min(a)));
425 }
426 };
427 for (stream, liveness, age) in c.streams() {
428 touch(&stream.runtime_key(), liveness, age);
429 }
430 for key in links.keys() {
431 touch(key, StreamLiveness::Expired, None);
432 }
433 for (key, node) in runtimes.iter_mut() {
434 node.link = links.get(key).cloned();
435 }
436 for claim in state.claims.iter().filter(|cl| cl.state.is_live()) {
437 runtimes.entry(claim.runtime.clone()).and_modify(|runtime| {
439 let newer = runtime
440 .newest_claim
441 .as_ref()
442 .is_none_or(|b| claim.acquired_lamport > b.acquired_lamport);
443 if newer {
444 runtime.newest_claim = Some(ClaimBrief {
445 key: claim.key.clone(),
446 session: claim.session.clone(),
447 issue: claim.issue.clone(),
448 intent: claim.intent.as_deref().map(page::cut),
449 paths: claim.scope.len(),
450 mode: claim.mode,
451 acquired_lamport: claim.acquired_lamport,
452 });
453 }
454 });
455 }
456 for session in &state.sessions {
457 if summary && session.state != SessionState::Active {
458 continue;
459 }
460 let claims: Vec<ClaimView> = state
461 .claims
462 .iter()
463 .filter(|cl| cl.session == session.key)
464 .cloned()
465 .collect();
466 let claims_held = claims.iter().filter(|cl| cl.state.is_live()).count();
467 let mut info = session.info.clone();
468 if summary {
469 info.intent = info.intent.as_deref().map(page::cut);
470 }
471 let reviews = state
472 .reviews
473 .iter()
474 .filter(|r| r.session == session.key)
475 .map(|r| r.key.clone())
476 .collect();
477 if let Some(runtime) = runtimes.get_mut(&session.runtime) {
478 runtime.sessions.push(SessionNode {
479 key: session.key.clone(),
480 info,
481 state: session.state,
482 claims: if summary { Vec::new() } else { claims },
483 claims_held,
484 reviews,
485 });
486 }
487 }
488
489 let own_name = status
490 .peers
491 .iter()
492 .find(|p| p.local)
493 .map(|p| p.name.clone());
494 let mut machines: BTreeMap<String, MachineNode> = BTreeMap::new();
495 for (key, runtime) in runtimes {
496 let node = key.get(..32).unwrap_or_default().to_string();
497 let name = runtime
498 .link
499 .as_ref()
500 .map(|l| l.name.clone())
501 .or_else(|| (node == own_node).then(|| own_name.clone()).flatten())
502 .unwrap_or_else(|| node.chars().take(8).collect());
503 machines
504 .entry(node.clone())
505 .or_insert_with(|| MachineNode {
506 node: node.clone(),
507 name,
508 local: node == own_node,
509 runtimes: Vec::new(),
510 })
511 .runtimes
512 .push(runtime);
513 }
514 let mut machines: Vec<MachineNode> = machines.into_values().collect();
515 crate::order::canonical(&mut machines);
516 PeerTree {
517 active: status.active,
518 view: query.view(),
519 reason: status.reason,
520 runtime: status.runtime,
521 repository: status.repository.map(|r| r.id),
522 machines,
523 refused: status.refused,
524 digest: Some(whole.digest),
525 }
526}
527
528fn mesh_peers(ctx: &Context, query: Query) -> Result<PeerTree, CapabilityError> {
529 Ok(match ctx.mesh.cooperation() {
530 Some(c) => peer_tree(&c, &query),
531 None => PeerTree {
532 active: false,
533 view: query.view(),
534 reason: ctx.mesh.cooperation_status().reason,
535 runtime: None,
536 repository: None,
537 machines: Vec::new(),
538 refused: Vec::new(),
539 digest: None,
540 },
541 })
542}
543
544#[derive(Debug, Serialize, Deserialize, JsonSchema)]
547#[serde(deny_unknown_fields)]
548pub struct PeerInput {
550 pub runtime: String,
552}
553
554impl BenchmarkCases for PeerInput {
555 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
556 vec![NamedCase::new(
557 "unknown",
558 PeerInput {
559 runtime: "0".repeat(49),
560 },
561 )]
562 }
563}
564
565#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
567pub struct PeerDetail {
568 pub found: bool,
570 #[serde(default, skip_serializing_if = "Option::is_none")]
572 pub reason: Option<String>,
573 #[serde(default, skip_serializing_if = "Option::is_none")]
575 pub machine: Option<String>,
576 #[serde(default, skip_serializing_if = "Option::is_none")]
578 pub runtime: Option<RuntimeNode>,
579 pub refused: Vec<RefusedView>,
581}
582
583fn mesh_peer(ctx: &Context, input: PeerInput) -> Result<PeerDetail, CapabilityError> {
584 let Some(c) = ctx.mesh.cooperation() else {
585 return Ok(PeerDetail {
586 found: false,
587 reason: ctx.mesh.cooperation_status().reason,
588 machine: None,
589 runtime: None,
590 refused: Vec::new(),
591 });
592 };
593 let full = Query {
594 view: Some(View::Full),
595 ..Query::default()
596 };
597 let tree = peer_tree(&c, &full);
598 let wanted = input.runtime.trim();
599 let hit = tree.machines.iter().find_map(|m| {
600 m.runtimes
601 .iter()
602 .find(|r| r.runtime == wanted || (wanted.len() == 32 && r.runtime.starts_with(wanted)))
603 .map(|r| (m.name.clone(), r.clone()))
604 });
605 let refused = tree
606 .refused
607 .into_iter()
608 .filter(|r| r.runtime.as_deref() == Some(wanted))
609 .collect();
610 Ok(match hit {
611 Some((machine, runtime)) => PeerDetail {
612 found: true,
613 reason: None,
614 machine: Some(machine),
615 runtime: Some(runtime),
616 refused,
617 },
618 None => PeerDetail {
619 found: false,
620 reason: Some(format!("no runtime {wanted} is linked or heard here")),
621 machine: None,
622 runtime: None,
623 refused,
624 },
625 })
626}
627
628#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
632pub struct StreamView {
633 pub stream: StreamId,
635 pub liveness: StreamLiveness,
637 #[serde(default, skip_serializing_if = "Option::is_none")]
639 pub last_beat_ms: Option<u64>,
640}
641
642#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
644pub struct MeshStateAnswer {
645 pub active: bool,
647 pub view: View,
649 #[serde(default, skip_serializing_if = "Option::is_none")]
651 pub reason: Option<String>,
652 #[serde(default, skip_serializing_if = "Option::is_none")]
655 pub summary: Option<Summary>,
656 #[serde(default, skip_serializing_if = "Option::is_none")]
659 pub state: Option<CooperationState>,
660 pub streams: Vec<StreamView>,
662}
663
664fn mesh_state(ctx: &Context, query: Query) -> Result<MeshStateAnswer, CapabilityError> {
665 let view = query.view();
666 let Some(c) = ctx.mesh.cooperation() else {
667 return Ok(MeshStateAnswer {
668 active: false,
669 view,
670 reason: ctx.mesh.cooperation_status().reason,
671 summary: None,
672 state: None,
673 streams: Vec::new(),
674 });
675 };
676 let state = page::narrow(&c.state(), &query);
677 let streams: Vec<StreamView> = c
678 .streams()
679 .into_iter()
680 .filter(|(stream, _, _)| query.admits_runtime(&stream.runtime_key()))
681 .map(|(stream, liveness, last_beat_ms)| StreamView {
682 stream,
683 liveness,
684 last_beat_ms,
685 })
686 .collect();
687 Ok(match view {
688 View::Summary => {
689 let beats: Vec<_> = streams
690 .iter()
691 .map(|s| (s.stream.runtime_key(), s.liveness, s.last_beat_ms))
692 .collect();
693 MeshStateAnswer {
694 active: true,
695 view,
696 reason: None,
697 summary: Some(page::summarize(&state, &beats)),
698 state: None,
699 streams: Vec::new(),
701 }
702 }
703 View::Full => MeshStateAnswer {
704 active: true,
705 view,
706 reason: None,
707 summary: None,
708 state: Some(state),
709 streams,
710 },
711 })
712}
713
714#[derive(Debug, Serialize, Deserialize, JsonSchema)]
717#[serde(deny_unknown_fields)]
718pub struct MeshEventsInput {
720 #[serde(default, skip_serializing_if = "Option::is_none")]
722 pub after: Option<u64>,
723 #[serde(default, skip_serializing_if = "Option::is_none")]
725 pub limit: Option<u64>,
726 #[serde(default, skip_serializing_if = "Option::is_none")]
731 pub newest: Option<bool>,
732}
733
734impl BenchmarkCases for MeshEventsInput {
735 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
736 vec![NamedCase::new(
737 "default",
738 MeshEventsInput {
739 after: None,
740 limit: Some(100),
741 newest: None,
742 },
743 )]
744 }
745}
746
747#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
749pub struct EventList {
750 pub active: bool,
752 pub count: usize,
754 pub lamport: u64,
758 pub events: Vec<MeshEvent>,
760}
761
762fn mesh_events(ctx: &Context, input: MeshEventsInput) -> Result<EventList, CapabilityError> {
763 let Some(c) = ctx.mesh.cooperation() else {
764 return Ok(EventList {
765 active: false,
766 count: 0,
767 lamport: 0,
768 events: Vec::new(),
769 });
770 };
771 let limit = input.limit.unwrap_or(100).clamp(1, 1000) as usize;
772 let after = input.after.unwrap_or(0);
773 let events = if input.newest == Some(true) {
774 let mut all = c.journal().events_after(after, usize::MAX);
777 let from = all.len().saturating_sub(limit);
778 all.drain(..from);
779 all
780 } else {
781 c.journal().events_after(after, limit)
782 };
783 Ok(EventList {
784 active: true,
785 count: events.len(),
786 lamport: events.last().map_or(after, |e| e.lamport),
787 events,
788 })
789}
790
791fn mesh_verify(ctx: &Context, _: Empty) -> Result<MeshVerifyReport, CapabilityError> {
794 Ok(match ctx.mesh.cooperation() {
795 Some(c) => c.verify(),
796 None => MeshVerifyReport::inactive(
797 &ctx.mesh
798 .cooperation_status()
799 .reason
800 .unwrap_or_else(|| "cooperation is not running".into()),
801 ),
802 })
803}
804
805#[derive(Debug, Serialize, Deserialize, JsonSchema)]
808#[serde(deny_unknown_fields)]
809pub struct SessionInput {
811 #[serde(default, skip_serializing_if = "Option::is_none")]
813 pub session: Option<String>,
814 #[serde(default, skip_serializing_if = "Option::is_none")]
816 pub client: Option<String>,
817 #[serde(default, skip_serializing_if = "Option::is_none")]
819 pub worker: Option<String>,
820 #[serde(default, skip_serializing_if = "Option::is_none")]
822 pub intent: Option<String>,
823 #[serde(default, skip_serializing_if = "Option::is_none")]
825 pub task: Option<String>,
826 #[serde(default, skip_serializing_if = "Option::is_none")]
828 pub issue: Option<String>,
829 #[serde(default, skip_serializing_if = "Option::is_none")]
831 pub milestone: Option<String>,
832 #[serde(default, skip_serializing_if = "Option::is_none")]
834 pub branch: Option<String>,
835 #[serde(default, skip_serializing_if = "Option::is_none")]
837 pub head: Option<String>,
838 #[serde(default, skip_serializing_if = "Option::is_none")]
840 pub dirty: Option<bool>,
841 #[serde(default, skip_serializing_if = "Option::is_none")]
843 pub context: Option<String>,
844}
845
846impl BenchmarkCases for SessionInput {
847 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
848 vec![NamedCase::new(
849 "named",
850 SessionInput {
851 session: Some("bench".into()),
852 client: None,
853 worker: None,
854 intent: Some("benchmark".into()),
855 task: None,
856 issue: None,
857 milestone: None,
858 branch: None,
859 head: None,
860 dirty: None,
861 context: None,
862 },
863 )]
864 }
865}
866
867fn mesh_session_open(ctx: &Context, input: SessionInput) -> Result<Written, CapabilityError> {
868 let c = cooperation(ctx)?;
869 let session = session_of(ctx, input.session)?;
870 c.open_session(SessionInfo {
871 session,
872 client: input.client.unwrap_or_else(|| "cli".into()),
873 worker: input.worker,
874 intent: input.intent,
875 task: input.task,
876 issue: input.issue,
877 milestone: input.milestone,
878 checkout: None,
879 branch: input.branch,
880 head: input.head,
881 dirty: input.dirty,
882 context: input.context,
883 })
884 .map_err(map_error)
885}
886
887#[derive(Debug, Serialize, Deserialize, JsonSchema)]
888#[serde(deny_unknown_fields)]
889pub struct SessionCloseInput {
891 #[serde(default, skip_serializing_if = "Option::is_none")]
893 pub session: Option<String>,
894}
895
896impl BenchmarkCases for SessionCloseInput {
897 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
898 vec![NamedCase::new(
899 "named",
900 SessionCloseInput {
901 session: Some("bench".into()),
902 },
903 )]
904 }
905}
906
907fn mesh_session_close(ctx: &Context, input: SessionCloseInput) -> Result<Written, CapabilityError> {
908 let c = cooperation(ctx)?;
909 let session = session_of(ctx, input.session)?;
910 c.close_session(&session).map_err(map_error)
911}
912
913#[derive(Debug, Serialize, Deserialize, JsonSchema)]
916#[serde(deny_unknown_fields)]
917pub struct ClaimInput {
919 #[serde(default, skip_serializing_if = "Option::is_none")]
921 pub session: Option<String>,
922 #[serde(default, skip_serializing_if = "Option::is_none")]
924 pub client: Option<String>,
925 pub scope: Vec<String>,
927 #[serde(default, skip_serializing_if = "Option::is_none")]
929 pub intent: Option<String>,
930 #[serde(default, skip_serializing_if = "Option::is_none")]
932 pub mode: Option<ClaimMode>,
933 #[serde(default, skip_serializing_if = "Option::is_none")]
935 pub issue: Option<String>,
936 #[serde(default, skip_serializing_if = "Option::is_none")]
938 pub task: Option<String>,
939}
940
941impl BenchmarkCases for ClaimInput {
942 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
943 vec![NamedCase::new(
944 "exclusive",
945 ClaimInput {
946 session: Some("bench".into()),
947 client: None,
948 scope: vec!["docs".into()],
949 intent: None,
950 mode: None,
951 issue: None,
952 task: None,
953 },
954 )]
955 }
956}
957
958fn mesh_claim(ctx: &Context, input: ClaimInput) -> Result<Written, CapabilityError> {
959 let c = cooperation(ctx)?;
960 let session = session_of(ctx, input.session)?;
961 let info = SessionInfo {
962 session,
963 client: input.client.unwrap_or_else(|| "cli".into()),
964 task: input.task,
965 issue: input.issue.clone(),
966 intent: input.intent.clone(),
967 ..SessionInfo::default()
968 };
969 c.claim(
970 &info,
971 input.scope,
972 input.intent,
973 input.mode.unwrap_or_default(),
974 input.issue,
975 )
976 .map_err(map_error)
977}
978
979#[derive(Debug, Serialize, Deserialize, JsonSchema)]
980#[serde(deny_unknown_fields)]
981pub struct ReleaseInput {
983 pub claim: String,
985}
986
987impl BenchmarkCases for ReleaseInput {
988 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
989 vec![NamedCase::new(
990 "unknown",
991 ReleaseInput {
992 claim: "none/c-0".into(),
993 },
994 )]
995 }
996}
997
998fn mesh_release(ctx: &Context, input: ReleaseInput) -> Result<Written, CapabilityError> {
999 cooperation(ctx)?.release(&input.claim).map_err(map_error)
1000}
1001
1002#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1005#[serde(deny_unknown_fields)]
1006pub struct HandoverPublishInput {
1008 #[serde(default, skip_serializing_if = "Option::is_none")]
1012 pub path: Option<String>,
1013 #[serde(default, skip_serializing_if = "Option::is_none")]
1015 pub issue: Option<String>,
1016 #[serde(default, skip_serializing_if = "Option::is_none")]
1018 pub milestone: Option<String>,
1019}
1020
1021impl BenchmarkCases for HandoverPublishInput {
1022 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
1023 vec![NamedCase::new(
1024 "latest",
1025 HandoverPublishInput {
1026 path: None,
1027 issue: None,
1028 milestone: None,
1029 },
1030 )]
1031 }
1032}
1033
1034fn mesh_handover_publish(
1035 ctx: &Context,
1036 input: HandoverPublishInput,
1037) -> Result<Written, CapabilityError> {
1038 let c = cooperation(ctx)?;
1039 let root = std::path::Path::new(&ctx.index.repository.root);
1040 let dir = crate::mesh::handover::directory(root);
1041 let path = match input.path {
1042 None => crate::mesh::handover::latest(root).map_err(CapabilityError::NotFound)?,
1043 Some(p) => {
1044 let candidate = root.join(p.trim_start_matches('/'));
1045 let (Ok(real), Ok(real_dir)) = (candidate.canonicalize(), dir.canonicalize()) else {
1046 return Err(CapabilityError::NotFound(format!(
1047 "no handover record at {p}"
1048 )));
1049 };
1050 if !real.starts_with(&real_dir) {
1051 return Err(CapabilityError::Refused(format!(
1052 "{p} is not a handover record: only files under .ai/local/state/handovers/ are published"
1053 )));
1054 }
1055 real
1056 }
1057 };
1058 let body = crate::mesh::handover::to_body(&path, input.issue, input.milestone)
1059 .map_err(CapabilityError::InvalidInput)?;
1060 c.publish_handover(body).map_err(map_error)
1061}
1062
1063#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1064#[serde(deny_unknown_fields)]
1065pub struct HandoverConsumeInput {
1067 pub handover: String,
1069 #[serde(default, skip_serializing_if = "Option::is_none")]
1071 pub session: Option<String>,
1072 #[serde(default, skip_serializing_if = "Option::is_none")]
1075 pub materialize: Option<bool>,
1076}
1077
1078impl BenchmarkCases for HandoverConsumeInput {
1079 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
1080 vec![NamedCase::new(
1081 "unknown",
1082 HandoverConsumeInput {
1083 handover: "0".repeat(32),
1084 session: Some("bench".into()),
1085 materialize: Some(false),
1086 },
1087 )]
1088 }
1089}
1090
1091#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1093pub struct ConsumeAnswer {
1094 pub handover: HandoverView,
1096 pub written: Written,
1098 #[serde(default, skip_serializing_if = "Option::is_none")]
1100 pub path: Option<String>,
1101}
1102
1103fn mesh_handover_consume(
1104 ctx: &Context,
1105 input: HandoverConsumeInput,
1106) -> Result<ConsumeAnswer, CapabilityError> {
1107 let c = cooperation(ctx)?;
1108 let session = session_of(ctx, input.session)?;
1109 let (view, written) = c
1110 .consume_handover(&input.handover, &session)
1111 .map_err(map_error)?;
1112 let root = std::path::Path::new(&ctx.index.repository.root);
1113 let path = if input.materialize.unwrap_or(true) {
1114 let written =
1115 crate::mesh::handover::materialize(root, &view).map_err(CapabilityError::Internal)?;
1116 Some(
1117 written
1118 .strip_prefix(root)
1119 .unwrap_or(&written)
1120 .display()
1121 .to_string(),
1122 )
1123 } else {
1124 None
1125 };
1126 Ok(ConsumeAnswer {
1127 handover: view,
1128 written,
1129 path,
1130 })
1131}
1132
1133#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1136#[serde(deny_unknown_fields)]
1137pub struct ReviewRequestInput {
1139 #[serde(default, skip_serializing_if = "Option::is_none")]
1141 pub session: Option<String>,
1142 pub subject: String,
1144 #[serde(default)]
1146 pub scope: Vec<String>,
1147 #[serde(default, skip_serializing_if = "Option::is_none")]
1149 pub issue: Option<String>,
1150 #[serde(default, skip_serializing_if = "Option::is_none")]
1152 pub reviewer: Option<String>,
1153 #[serde(default, skip_serializing_if = "Option::is_none")]
1156 pub body: Option<String>,
1157}
1158
1159impl BenchmarkCases for ReviewRequestInput {
1160 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
1161 vec![NamedCase::new(
1162 "open",
1163 ReviewRequestInput {
1164 session: Some("bench".into()),
1165 subject: "feature/bench".into(),
1166 scope: vec![],
1167 issue: None,
1168 reviewer: None,
1169 body: None,
1170 },
1171 )]
1172 }
1173}
1174
1175fn mesh_review_request(
1176 ctx: &Context,
1177 input: ReviewRequestInput,
1178) -> Result<Written, CapabilityError> {
1179 let c = cooperation(ctx)?;
1180 let session = session_of(ctx, input.session)?;
1181 c.request_review(
1182 &session,
1183 input.subject,
1184 input.scope,
1185 input.issue,
1186 input.reviewer,
1187 input.body,
1188 )
1189 .map_err(map_error)
1190}
1191
1192#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1193#[serde(deny_unknown_fields)]
1194pub struct ReviewAnswerInput {
1196 pub request: String,
1198 #[serde(default, skip_serializing_if = "Option::is_none")]
1200 pub session: Option<String>,
1201 pub verdict: String,
1203 #[serde(default, skip_serializing_if = "Option::is_none")]
1205 pub note: Option<String>,
1206}
1207
1208impl BenchmarkCases for ReviewAnswerInput {
1209 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
1210 vec![NamedCase::new(
1211 "unknown",
1212 ReviewAnswerInput {
1213 request: "none/r-0".into(),
1214 session: Some("bench".into()),
1215 verdict: "commented".into(),
1216 note: None,
1217 },
1218 )]
1219 }
1220}
1221
1222fn mesh_review_answer(ctx: &Context, input: ReviewAnswerInput) -> Result<Written, CapabilityError> {
1223 let c = cooperation(ctx)?;
1224 let session = session_of(ctx, input.session)?;
1225 c.answer_review(&input.request, &session, input.verdict, input.note)
1226 .map_err(map_error)
1227}
1228
1229#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1230#[serde(deny_unknown_fields)]
1231pub struct ReviewWithdrawInput {
1233 pub request: String,
1235 #[serde(default, skip_serializing_if = "Option::is_none")]
1238 pub session: Option<String>,
1239 #[serde(default, skip_serializing_if = "Option::is_none")]
1241 pub note: Option<String>,
1242}
1243
1244impl BenchmarkCases for ReviewWithdrawInput {
1245 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
1246 vec![NamedCase::new(
1247 "unknown",
1248 ReviewWithdrawInput {
1249 request: "none/r-0".into(),
1250 session: Some("bench".into()),
1251 note: Some("superseded".into()),
1252 },
1253 )]
1254 }
1255}
1256
1257fn mesh_review_withdraw(
1258 ctx: &Context,
1259 input: ReviewWithdrawInput,
1260) -> Result<Written, CapabilityError> {
1261 let c = cooperation(ctx)?;
1262 let session = session_of(ctx, input.session)?;
1263 c.withdraw_review(&input.request, &session, input.note)
1264 .map_err(map_error)
1265}
1266
1267impl BenchmarkCases for Signed {
1270 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
1271 vec![NamedCase::new(
1273 "malformed",
1274 Signed {
1275 body: serde_json::json!({}),
1276 sig: String::new(),
1277 },
1278 )]
1279 }
1280}
1281
1282fn not_active(ctx: &Context) -> LinkReply {
1283 LinkReply::refused(
1284 RefusalCode::NotActive,
1285 ctx.mesh
1286 .cooperation_status()
1287 .reason
1288 .unwrap_or_else(|| "cooperation is not running".into()),
1289 )
1290}
1291
1292fn mesh_link_hello(ctx: &Context, input: Signed) -> Result<LinkReply, CapabilityError> {
1293 Ok(match ctx.mesh.cooperation() {
1294 Some(c) => c.accept_hello(&input),
1295 None => not_active(ctx),
1296 })
1297}
1298
1299fn mesh_link_sync(ctx: &Context, input: Signed) -> Result<LinkReply, CapabilityError> {
1300 Ok(match ctx.mesh.cooperation() {
1301 Some(c) => c.accept_sync(&input),
1302 None => not_active(ctx),
1303 })
1304}
1305
1306pub fn module() -> ModuleDescriptor {
1308 module! {
1309 id: "mesh",
1310 title: "Mesh",
1311 description: "The mesh of this process: discovery — authenticated observations of other running Majordomus instances in one registry — and cooperation — authenticated links to trusted runtimes of the same repository, replicating sessions, claims, handovers and reviews through one journal every runtime folds into the same state. Discovery grants nothing; a link is admitted per peer, and nothing a peer sends executes anything here.",
1312 stability: Stability::Experimental,
1313 capabilities: [
1314 capability! {
1315 id: "mesh.status",
1316 title: "The mesh, at a glance",
1317 description: "Whether the mesh runs in this process and why not when it does not; this node's public identity; every discovery provider with its state and counters; the registry's tallies; and the refused datagrams by reason. The one status every surface shows.",
1318 input: Empty,
1319 output: MeshStatus,
1320 stability: Stability::Experimental,
1321 exposure: Exposure {
1322 mcp: Some(crate::capability::model::McpExposure {
1323 tool: Some("majordomus_mesh".into()),
1324 resource: Some(crate::capability::model::McpResource {
1325 uri: MESH_URI.into(),
1326 name: "mesh".into(),
1327 }),
1328 }),
1329 http: get("/api/v1/mesh"),
1330 cli: None,
1331 },
1332 tags: ["mesh", "coordination", "discovery"],
1333 handler: mesh_status,
1334 },
1335 capability! {
1336 id: "mesh.nodes",
1337 title: "The discovered nodes",
1338 description: "Every runtime this process has observed, one record per node and runtime slot, deduplicated across every discovery source, in node-id order: identity, trust, presence, endpoints, capabilities, repositories, provenance and versions. In memory, gone with the process.",
1339 input: Empty,
1340 output: NodeList,
1341 stability: Stability::Experimental,
1342 exposure: Exposure { mcp: mcp("majordomus_mesh_nodes"), http: get("/api/v1/mesh/nodes"), cli: None },
1343 tags: ["mesh", "coordination", "discovery"],
1344 handler: mesh_nodes,
1345 },
1346 capability! {
1347 id: "mesh.identity",
1348 title: "This machine's node identity",
1349 description: "The node identity kept under the user's state directory, public half only: node id, public key, display name. The signing key appears in no projection. Absent is an answer, not an error — the identity is created when a mesh first activates.",
1350 input: Empty,
1351 output: MeshIdentityReport,
1352 stability: Stability::Experimental,
1353 exposure: Exposure {
1354 mcp: mcp("majordomus_mesh_identity"),
1355 http: get("/api/v1/mesh/identity"),
1356 cli: Some(CliExposure { path: vec!["mesh".into(), "identity".into()] }),
1357 },
1358 tags: ["mesh", "identity"],
1359 handler: mesh_identity,
1360 },
1361 capability! {
1362 id: "mesh.doctor",
1363 title: "The mesh self-check",
1364 description: "Every prerequisite proved on this machine alone — the declaration parses, the identity loads and, under an allowlist, is on it, the repository has a mesh identity, a UDP socket binds, the multicast group joins, broadcast enables, and the discovery and link protocols sign and verify end to end in memory — and then the server's own verdict: in a shared server, whether an enabled declaration actually activated the mesh and, when it did not, the server's reason, which fails the report. Each failed check names its impact and its remedy. Deterministic, no second node required.",
1365 input: Empty,
1366 output: MeshDoctorReport,
1367 stability: Stability::Experimental,
1368 exposure: Exposure {
1369 mcp: mcp("majordomus_mesh_doctor"),
1370 http: get("/api/v1/mesh/doctor"),
1371 cli: Some(CliExposure { path: vec!["mesh".into(), "doctor".into()] }),
1372 },
1373 tags: ["mesh", "diagnostics"],
1374 handler: mesh_doctor,
1375 },
1376 capability! {
1377 id: "mesh.firewall",
1378 title: "The host firewall, as the mesh needs it",
1379 description: "What inbound traffic the declaration implies this machine must admit — the multicast group's port, the broadcast port when it is a permitted fallback, every declared hub port whose address is this machine's, and the server's own port when it listens beyond loopback — from the private networks the declaration names; which firewall front this host runs (ufw, nftables, the macOS application firewall); the commands that admit the plan there; what the firewall itself says about the plan now (present, missing, inactive, or unobservable without root); and what the kernel logged it dropping toward those ports in the last five minutes. Fails when a rule is observed missing or a drop was logged. Reads the host; changes nothing.",
1380 input: FirewallInput,
1381 output: FirewallReport,
1382 stability: Stability::Experimental,
1383 exposure: Exposure {
1384 mcp: mcp("majordomus_mesh_firewall"),
1385 http: get("/api/v1/mesh/firewall"),
1386 cli: Some(CliExposure { path: vec!["mesh".into(), "firewall".into()] }),
1387 },
1388 tags: ["mesh", "diagnostics", "firewall"],
1389 cache: CachePolicy::Disabled,
1390 benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::ExternalDependency },
1391 handler: mesh_firewall,
1392 },
1393 capability! {
1394 id: "mesh.firewall.apply",
1395 kind: CapabilityKind::Command,
1396 title: "Admit the mesh through the host firewall",
1397 description: "Run the commands mesh.firewall renders, on this host, as root: one allow per rule and source network on ufw or nftables, the executable admitted on the macOS application firewall; nothing else is touched, and every rule written carries the comment `majordomus mesh` so it can be told from an operator's own. Refuses, running nothing, without root or without a backend; records every command with its exit and output; and asks the firewall again afterwards, so the verdict is the firewall's. Offered on the command line only: it runs a privileged host tool, which nothing reachable over HTTP or MCP may do.",
1398 input: FirewallInput,
1399 output: FirewallApplyReport,
1400 stability: Stability::Experimental,
1401 exposure: Exposure {
1402 mcp: None,
1403 http: None,
1404 cli: Some(CliExposure { path: vec!["mesh".into(), "firewall".into(), "apply".into()] }),
1405 },
1406 tags: ["mesh", "firewall"],
1407 cache: CachePolicy::Disabled,
1408 benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::Destructive },
1409 handler: mesh_firewall_apply,
1410 },
1411 capability! {
1412 id: "mesh.register",
1413 kind: CapabilityKind::Command,
1414 title: "Register with this node's mesh",
1415 description: "Present one signed envelope; it is verified exactly as a datagram — bounds, staleness, signature, trust policy — and recorded as a rendezvous observation when it holds. The answer carries this node's own envelope and the candidates its registry holds, each verifiable end to end on its own signature. Registration grants nothing: the caller becomes a record, never an authorization. Changes this process's memory only.",
1416 input: RegisterInput,
1417 output: RegisterAnswer,
1418 stability: Stability::Experimental,
1419 exposure: Exposure { mcp: mcp("majordomus_mesh_register"), http: post(REGISTER_PATH), cli: None },
1420 tags: ["mesh", "coordination", "discovery"],
1421 handler: mesh_register,
1422 }
1423 .authenticates_its_input(),
1426 capability! {
1427 id: "mesh.cooperation",
1428 title: "Cooperation, at a glance",
1429 description: "Whether this runtime cooperates and why not when it does not: its runtime and stream, the repository identity links are matched on, the link protocol and features, the heartbeat and expiry, every linked peer with its link state, last exchange, round trip and failures, every refused candidate with the rule that refused it, and the counters of handshakes, syncs, reconnects, expiries, replicated and refused events.",
1430 input: Empty,
1431 output: CooperationStatus,
1432 stability: Stability::Experimental,
1433 exposure: Exposure { mcp: mcp("majordomus_mesh_cooperation"), http: get("/api/v1/mesh/cooperation"), cli: None },
1434 tags: ["mesh", "coordination", "cooperation"],
1435 handler: mesh_cooperation,
1436 },
1437 capability! {
1438 id: "mesh.peers",
1439 title: "Who cooperates, machine by machine",
1440 description: "Machine → runtime → session → claim: every runtime this one is linked to or has heard through the journal, grouped by machine, local first; each runtime with its liveness, the milliseconds since its beat rose, its link and its newest live claim. The default `summary` view lists only the active sessions, each with its live claims counted and its intent cut; `view=full` lists every session with every claim. `session` and `runtime` narrow either. Remote and local runtimes share one shape, and the whole fold's digest closes the answer: equal digests, equal state.",
1441 input: Query,
1442 output: PeerTree,
1443 stability: Stability::Experimental,
1444 exposure: Exposure { mcp: mcp("majordomus_mesh_peers"), http: get("/api/v1/mesh/peers"), cli: None },
1445 tags: ["mesh", "coordination", "cooperation"],
1446 handler: mesh_peers,
1447 },
1448 capability! {
1449 id: "mesh.peer",
1450 title: "One runtime",
1451 description: "One runtime by its key (`<node>-<runtime>`, or a node id for its first runtime): the machine it runs on, its liveness, its link from here, its sessions and claims, and any refusal recorded against it. Not found is an answer with the reason.",
1452 input: PeerInput,
1453 output: PeerDetail,
1454 stability: Stability::Experimental,
1455 exposure: Exposure { mcp: mcp("majordomus_mesh_peer"), http: get("/api/v1/mesh/peer"), cli: None },
1456 tags: ["mesh", "coordination", "cooperation"],
1457 handler: mesh_peer,
1458 },
1459 capability! {
1460 id: "mesh.state",
1461 title: "The state every linked runtime converges on",
1462 description: "The journal folded. The default `summary` view is bounded whatever the history: sessions and claims counted by standing, each runtime with its liveness, its counts and its newest live claim, the open review requests and the handovers nobody has taken, and the digest two runtimes compare. `view=full` is every session, every claim with its standing (held, released, expired, conflicted with its winner), the advisory overlaps, every handover with who consumed it, every review request with its answers, and every stream's liveness on this runtime's clock. `claims=live`, `after` (a Lamport stamp), `session` and `runtime` narrow either view; the digest is always the whole fold's.",
1463 input: Query,
1464 output: MeshStateAnswer,
1465 stability: Stability::Experimental,
1466 exposure: Exposure { mcp: mcp("majordomus_mesh_state"), http: get("/api/v1/mesh/state"), cli: None },
1467 tags: ["mesh", "coordination", "cooperation"],
1468 handler: mesh_state,
1469 },
1470 capability! {
1471 id: "mesh.events",
1472 title: "The cooperation journal",
1473 description: "The journal's events above a Lamport stamp, in Lamport order, at most a page: each with its stream, sequence, stamp, repository, signing key, kind and body. The answer's lamport is the next page's `after`.",
1474 input: MeshEventsInput,
1475 output: EventList,
1476 stability: Stability::Experimental,
1477 exposure: Exposure { mcp: mcp("majordomus_mesh_events"), http: get("/api/v1/mesh/events"), cli: None },
1478 tags: ["mesh", "coordination", "cooperation"],
1479 handler: mesh_events,
1480 },
1481 capability! {
1482 id: "mesh.verify",
1483 kind: CapabilityKind::Command,
1484 title: "Prove cooperation now",
1485 description: "Run a live verification: cooperation is active, the repository identity resolves, the endpoints are reachable beyond loopback, the heartbeat beats, the journal holds no stuck gap; then one sync round with every peer this runtime dials, timed, and whether both sides now hold the same high-water marks. Each failed check names its impact and remedy. Changes nothing but the journal's replication.",
1486 input: Empty,
1487 output: MeshVerifyReport,
1488 stability: Stability::Experimental,
1489 exposure: Exposure { mcp: mcp("majordomus_mesh_verify"), http: post("/api/v1/mesh/verify"), cli: None },
1490 tags: ["mesh", "diagnostics", "cooperation"],
1491 benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::ExternalDependency },
1492 handler: mesh_verify,
1493 },
1494 capability! {
1495 id: "mesh.session.open",
1496 kind: CapabilityKind::Command,
1497 title: "Open or update a session on the mesh",
1498 description: "Say what a session of this runtime is: its client, worker, intent, task, issue, milestone, branch, head, dirtiness and context revision. Replicated to every linked runtime; a later word replaces an earlier one. Writes this runtime's journal only.",
1499 input: SessionInput,
1500 output: Written,
1501 stability: Stability::Experimental,
1502 exposure: Exposure { mcp: mcp("majordomus_mesh_session_open"), http: post("/api/v1/mesh/sessions"), cli: None },
1503 tags: ["mesh", "coordination", "cooperation"],
1504 benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::TransientState },
1505 handler: mesh_session_open,
1506 },
1507 capability! {
1508 id: "mesh.session.close",
1509 kind: CapabilityKind::Command,
1510 title: "Close a session on the mesh",
1511 description: "End a session of this runtime; every claim it holds ends with it, on every linked runtime. Writes this runtime's journal only.",
1512 input: SessionCloseInput,
1513 output: Written,
1514 stability: Stability::Experimental,
1515 exposure: Exposure { mcp: mcp("majordomus_mesh_session_close"), http: post("/api/v1/mesh/sessions/close"), cli: None },
1516 tags: ["mesh", "coordination", "cooperation"],
1517 benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::TransientState },
1518 handler: mesh_session_close,
1519 },
1520 capability! {
1521 id: "mesh.claim",
1522 kind: CapabilityKind::Command,
1523 title: "Claim a scope across the mesh",
1524 description: "Claim repository paths for a session. An exclusive claim that meets a live exclusive claim of another session — on this runtime or any runtime this one has heard — is refused as `claim_conflict` with the claims it meets; an advisory claim is recorded and its overlaps reported. A claim lives while its session is open and its runtime beats: a crashed holder's claim expires everywhere on its own. Writes this runtime's journal only.",
1525 input: ClaimInput,
1526 output: Written,
1527 stability: Stability::Experimental,
1528 exposure: Exposure { mcp: mcp("majordomus_mesh_claim"), http: post("/api/v1/mesh/claims"), cli: None },
1529 tags: ["mesh", "coordination", "cooperation"],
1530 benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::TransientState },
1531 handler: mesh_claim,
1532 },
1533 capability! {
1534 id: "mesh.release",
1535 kind: CapabilityKind::Command,
1536 title: "Release a claim",
1537 description: "Release a claim this runtime's current run holds, by its key. A claim written elsewhere is refused as `not_own`: only its holder releases it, and a dead holder's claim expires instead. Writes this runtime's journal only.",
1538 input: ReleaseInput,
1539 output: Written,
1540 stability: Stability::Experimental,
1541 exposure: Exposure { mcp: mcp("majordomus_mesh_release"), http: post("/api/v1/mesh/claims/release"), cli: None },
1542 tags: ["mesh", "coordination", "cooperation"],
1543 benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::TransientState },
1544 handler: mesh_release,
1545 },
1546 capability! {
1547 id: "mesh.handover.publish",
1548 kind: CapabilityKind::Command,
1549 title: "Publish a handover to the mesh",
1550 description: "Publish a handover record of this checkout — the newest, or the one named under .ai/local/state/handovers/ — to every linked runtime: its task, branch, head and time from its front matter, the issue and milestone given, and its Markdown body, bounded and identified by the body's digest. No path of this machine travels, and no file outside the handovers directory is ever read.",
1551 input: HandoverPublishInput,
1552 output: Written,
1553 stability: Stability::Experimental,
1554 exposure: Exposure { mcp: mcp("majordomus_mesh_handover_publish"), http: post("/api/v1/mesh/handovers"), cli: None },
1555 tags: ["mesh", "continuity", "cooperation"],
1556 benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::TransientState },
1557 handler: mesh_handover_publish,
1558 },
1559 capability! {
1560 id: "mesh.handover.consume",
1561 kind: CapabilityKind::Command,
1562 title: "Consume a handover from the mesh",
1563 description: "Take a handover another runtime published: record the consumption on the mesh, and write it into this checkout's handovers directory as a record `majordomus handover --resolve` finds on the same branch — once, however often it is consumed.",
1564 input: HandoverConsumeInput,
1565 output: ConsumeAnswer,
1566 stability: Stability::Experimental,
1567 exposure: Exposure { mcp: mcp("majordomus_mesh_handover_consume"), http: post("/api/v1/mesh/handovers/consume"), cli: None },
1568 tags: ["mesh", "continuity", "cooperation"],
1569 benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::TransientState },
1570 handler: mesh_handover_consume,
1571 },
1572 capability! {
1573 id: "mesh.review.request",
1574 kind: CapabilityKind::Command,
1575 title: "Ask the mesh for a review",
1576 description: "Ask for a review of a branch, commit or pull request, optionally of one named runtime — which must be linked and carry the `reviews` feature, or the request is refused as `feature_unsupported`. Replicated to every linked runtime. Writes this runtime's journal only.",
1577 input: ReviewRequestInput,
1578 output: Written,
1579 stability: Stability::Experimental,
1580 exposure: Exposure { mcp: mcp("majordomus_mesh_review_request"), http: post("/api/v1/mesh/reviews"), cli: None },
1581 tags: ["mesh", "coordination", "cooperation"],
1582 benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::TransientState },
1583 handler: mesh_review_request,
1584 },
1585 capability! {
1586 id: "mesh.review.answer",
1587 kind: CapabilityKind::Command,
1588 title: "Answer a review request",
1589 description: "Answer a review request from any runtime with approved, changes_requested or commented, and a note. Replicated to every linked runtime. Writes this runtime's journal only.",
1590 input: ReviewAnswerInput,
1591 output: Written,
1592 stability: Stability::Experimental,
1593 exposure: Exposure { mcp: mcp("majordomus_mesh_review_answer"), http: post("/api/v1/mesh/reviews/answer"), cli: None },
1594 tags: ["mesh", "coordination", "cooperation"],
1595 benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::TransientState },
1596 handler: mesh_review_answer,
1597 },
1598 capability! {
1599 id: "mesh.review.withdraw",
1600 kind: CapabilityKind::Command,
1601 title: "Withdraw a review request",
1602 description: "End a review request this runtime asked for — done, void or superseded — with an optional result note the request then carries. Any session of the asking runtime may (a reconnected asker is another session of it); no other runtime may, and it is never counted as an answer. An unanswered request also reads expired while its runtime has stopped beating. Replicated to every linked runtime. Writes this runtime's journal only.",
1603 input: ReviewWithdrawInput,
1604 output: Written,
1605 stability: Stability::Experimental,
1606 exposure: Exposure { mcp: mcp("majordomus_mesh_review_withdraw"), http: post("/api/v1/mesh/reviews/withdraw"), cli: None },
1607 tags: ["mesh", "coordination", "cooperation"],
1608 benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::TransientState },
1609 handler: mesh_review_withdraw,
1610 },
1611 capability! {
1612 id: "mesh.link.hello",
1613 kind: CapabilityKind::Command,
1614 title: "Open a link (the handshake)",
1615 description: "The link handshake another runtime posts: a signed hello carrying its runtime card, a fresh nonce and its protocol range. Refused, typed, when malformed, oversized, mis-signed, stale, replayed, of an unsupported protocol, of another repository, untrusted, or this runtime itself; otherwise answered with a signed welcome: this runtime's card, a link id, its marks and the echoed nonce. Changes this process's link table only.",
1616 input: Signed,
1617 output: LinkReply,
1618 stability: Stability::Experimental,
1619 exposure: Exposure { mcp: None, http: post(HELLO_PATH), cli: None },
1620 tags: ["mesh", "cooperation", "link"],
1621 handler: mesh_link_hello,
1622 }
1623 .authenticates_its_input(),
1625 capability! {
1626 id: "mesh.link.sync",
1627 kind: CapabilityKind::Command,
1628 title: "One sync round of a link",
1629 description: "The replication round a linked runtime posts every heartbeat, signed under its link id with a rising counter: its marks and the events this runtime lacks. Ingested — verified end to end, deduplicated, applied in stream order — and answered, signed, with this runtime's marks and the events the peer lacks. An unknown link or a restarted peer is told to say hello again.",
1630 input: Signed,
1631 output: LinkReply,
1632 stability: Stability::Experimental,
1633 exposure: Exposure { mcp: None, http: post(SYNC_PATH), cli: None },
1634 tags: ["mesh", "cooperation", "link"],
1635 handler: mesh_link_sync,
1636 }
1637 .authenticates_its_input(),
1639 ],
1640 }
1641}
1642
1643#[cfg(test)]
1644mod tests {
1645 use super::*;
1646
1647 #[test]
1650 fn the_declaration_yields_the_projections_it_claims() {
1651 let m = module();
1652 assert_eq!(m.id.as_str(), "mesh");
1653 let expected: &[(&str, Option<&str>, &str)] = &[
1654 ("mesh.status", Some("majordomus_mesh"), "/api/v1/mesh"),
1655 (
1656 "mesh.nodes",
1657 Some("majordomus_mesh_nodes"),
1658 "/api/v1/mesh/nodes",
1659 ),
1660 (
1661 "mesh.identity",
1662 Some("majordomus_mesh_identity"),
1663 "/api/v1/mesh/identity",
1664 ),
1665 (
1666 "mesh.doctor",
1667 Some("majordomus_mesh_doctor"),
1668 "/api/v1/mesh/doctor",
1669 ),
1670 (
1671 "mesh.firewall",
1672 Some("majordomus_mesh_firewall"),
1673 "/api/v1/mesh/firewall",
1674 ),
1675 ("mesh.firewall.apply", None, ""),
1677 (
1678 "mesh.register",
1679 Some("majordomus_mesh_register"),
1680 REGISTER_PATH,
1681 ),
1682 (
1683 "mesh.cooperation",
1684 Some("majordomus_mesh_cooperation"),
1685 "/api/v1/mesh/cooperation",
1686 ),
1687 (
1688 "mesh.peers",
1689 Some("majordomus_mesh_peers"),
1690 "/api/v1/mesh/peers",
1691 ),
1692 (
1693 "mesh.peer",
1694 Some("majordomus_mesh_peer"),
1695 "/api/v1/mesh/peer",
1696 ),
1697 (
1698 "mesh.state",
1699 Some("majordomus_mesh_state"),
1700 "/api/v1/mesh/state",
1701 ),
1702 (
1703 "mesh.events",
1704 Some("majordomus_mesh_events"),
1705 "/api/v1/mesh/events",
1706 ),
1707 (
1708 "mesh.verify",
1709 Some("majordomus_mesh_verify"),
1710 "/api/v1/mesh/verify",
1711 ),
1712 (
1713 "mesh.session.open",
1714 Some("majordomus_mesh_session_open"),
1715 "/api/v1/mesh/sessions",
1716 ),
1717 (
1718 "mesh.session.close",
1719 Some("majordomus_mesh_session_close"),
1720 "/api/v1/mesh/sessions/close",
1721 ),
1722 (
1723 "mesh.claim",
1724 Some("majordomus_mesh_claim"),
1725 "/api/v1/mesh/claims",
1726 ),
1727 (
1728 "mesh.release",
1729 Some("majordomus_mesh_release"),
1730 "/api/v1/mesh/claims/release",
1731 ),
1732 (
1733 "mesh.handover.publish",
1734 Some("majordomus_mesh_handover_publish"),
1735 "/api/v1/mesh/handovers",
1736 ),
1737 (
1738 "mesh.handover.consume",
1739 Some("majordomus_mesh_handover_consume"),
1740 "/api/v1/mesh/handovers/consume",
1741 ),
1742 (
1743 "mesh.review.request",
1744 Some("majordomus_mesh_review_request"),
1745 "/api/v1/mesh/reviews",
1746 ),
1747 (
1748 "mesh.review.answer",
1749 Some("majordomus_mesh_review_answer"),
1750 "/api/v1/mesh/reviews/answer",
1751 ),
1752 (
1753 "mesh.review.withdraw",
1754 Some("majordomus_mesh_review_withdraw"),
1755 "/api/v1/mesh/reviews/withdraw",
1756 ),
1757 ("mesh.link.hello", None, HELLO_PATH),
1758 ("mesh.link.sync", None, SYNC_PATH),
1759 ];
1760 let ids: Vec<&str> = m
1761 .capabilities
1762 .iter()
1763 .map(|e| e.capability.id.as_str())
1764 .collect();
1765 let want: Vec<&str> = expected.iter().map(|(id, _, _)| *id).collect();
1766 assert_eq!(
1767 ids, want,
1768 "the module declares a different set of capabilities"
1769 );
1770 for (executable, (id, tool, path)) in m.capabilities.iter().zip(expected) {
1771 let exposure = &executable.capability.exposure;
1772 assert_eq!(
1773 exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
1774 *tool,
1775 "{id} lost or renamed its MCP tool"
1776 );
1777 assert_eq!(
1778 exposure.http.as_ref().map(|h| h.path.as_str()),
1779 (!path.is_empty()).then_some(*path),
1780 "{id} lost or renamed its HTTP route"
1781 );
1782 }
1783 }
1784
1785 #[test]
1789 fn what_changes_state_is_a_command_and_nothing_writes_the_repository() {
1790 let queries = [
1791 "mesh.status",
1792 "mesh.nodes",
1793 "mesh.identity",
1794 "mesh.doctor",
1795 "mesh.firewall",
1796 "mesh.cooperation",
1797 "mesh.peers",
1798 "mesh.peer",
1799 "mesh.state",
1800 "mesh.events",
1801 ];
1802 for e in module().capabilities {
1803 let c = &e.capability;
1804 let is_query = queries.contains(&c.id.as_str());
1805 assert_eq!(
1806 c.kind == CapabilityKind::Command,
1807 !is_query,
1808 "{}: reads are queries, changes are commands",
1809 c.id
1810 );
1811 if !is_query {
1812 match c.exposure.http.as_ref() {
1813 Some(h) => assert_eq!(
1814 h.method.as_str(),
1815 "POST",
1816 "{} changes state and must be a POST",
1817 c.id
1818 ),
1819 None => assert_eq!(
1822 c.id.as_str(),
1823 "mesh.firewall.apply",
1824 "{}: a command without an HTTP route must be the firewall's",
1825 c.id
1826 ),
1827 }
1828 }
1829 assert_ne!(
1830 c.execution.effect,
1831 crate::capability::model::Effect::RepositoryMutation,
1832 "{}: the mesh writes no tracked file",
1833 c.id
1834 );
1835 }
1836 }
1837
1838 fn cooperation() -> std::sync::Arc<Cooperation> {
1840 crate::mesh::testing::cooperation()
1841 }
1842
1843 fn context(declared: bool) -> (crate::synthetic::SyntheticRepository, Context) {
1846 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
1847 let mut index = repo.index().unwrap();
1848 if declared {
1849 index.objects.push(crate::model::Object {
1850 kind: crate::mesh::KIND.into(),
1851 identity: "majordomus".into(),
1852 uri: "majordomus://mesh-declaration/majordomus".into(),
1853 title: None,
1854 description: None,
1855 metadata: serde_json::json!({
1856 "schema": "mesh/v1", "kind": "mesh-declaration", "id": "majordomus",
1857 "enabled": true, "trust": { "policy": "deny_unknown", "allow": [] },
1858 }),
1859 body: String::new(),
1860 content: String::new(),
1861 media_type: "application/yaml",
1862 provenance: crate::model::Provenance {
1863 path: ".ai/repo/mesh/majordomus.yaml".into(),
1864 directory: ".ai/repo/mesh".into(),
1865 source_class: "mesh-declaration".into(),
1866 section: None,
1867 bytes: 0,
1868 member: None,
1869 },
1870 });
1871 }
1872 let registry = capability::CapabilityRegistry::builder()
1873 .with_modules(vec![module()])
1874 .with_index(&index)
1875 .build()
1876 .expect("the registry builds");
1877 let ctx = Context::new(std::sync::Arc::new(index), std::sync::Arc::new(registry));
1878 (repo, ctx)
1879 }
1880
1881 #[test]
1882 fn retrust_asks_the_running_cooperation_with_the_declaration_in_force() {
1883 let (_repo, ctx) = context(true);
1884 assert!(
1885 matches!(declaration(&ctx), Some(Ok(_))),
1886 "the declaration parses"
1887 );
1888 assert!(retrust(&ctx).is_empty(), "no cooperation, nothing to end");
1889 ctx.mesh.attach_cooperation(cooperation());
1890 assert!(
1891 retrust(&ctx).is_empty(),
1892 "a runtime with no links ends none"
1893 );
1894 let (_repo, undeclared) = context(false);
1895 undeclared.mesh.attach_cooperation(cooperation());
1896 assert!(
1897 retrust(&undeclared).is_empty(),
1898 "no declaration, no trust to put in force"
1899 );
1900 }
1901
1902 #[test]
1903 fn a_peer_asked_of_a_server_without_cooperation_is_not_found_with_the_reason() {
1904 let (_repo, ctx) = context(false);
1905 ctx.mesh.decline_cooperation("no endpoint to be dialed at");
1906 let detail = mesh_peer(
1907 &ctx,
1908 PeerInput {
1909 runtime: "anything".into(),
1910 },
1911 )
1912 .unwrap();
1913 assert!(!detail.found);
1914 assert_eq!(
1915 detail.reason.as_deref(),
1916 Some("no endpoint to be dialed at")
1917 );
1918 assert!(detail.machine.is_none() && detail.runtime.is_none() && detail.refused.is_empty());
1919 }
1920
1921 #[test]
1922 fn a_peer_is_found_by_its_runtime_key_in_the_full_tree_and_another_is_not() {
1923 let (_repo, ctx) = context(false);
1924 let c = cooperation();
1925 ctx.mesh.attach_cooperation(std::sync::Arc::clone(&c));
1926 let own = c.runtime_key().to_string();
1927 let found = mesh_peer(
1928 &ctx,
1929 PeerInput {
1930 runtime: format!(" {own} "),
1931 },
1932 )
1933 .unwrap();
1934 assert!(found.found, "{found:?}");
1935 assert_eq!(found.runtime.map(|r| r.runtime), Some(own));
1936 let missing = mesh_peer(
1937 &ctx,
1938 PeerInput {
1939 runtime: "nobody".into(),
1940 },
1941 )
1942 .unwrap();
1943 assert!(!missing.found);
1944 assert_eq!(
1945 missing.reason.as_deref(),
1946 Some("no runtime nobody is linked or heard here")
1947 );
1948 }
1949
1950 #[test]
1951 fn the_summary_tree_lists_active_sessions_counted_and_the_full_one_every_claim() {
1952 let c = cooperation();
1953 let s1 = SessionInfo::named("s1", "test");
1954 c.open_session(s1.clone()).unwrap();
1955 for (claim, path, issue) in [("z1", "apps", "#1"), ("a2", "docs", "#2")] {
1958 c.journal()
1959 .append_own(crate::mesh::journal::EventBody::ClaimAcquired {
1960 claim: claim.into(),
1961 session: s1.session.clone(),
1962 scope: vec![path.into()],
1963 intent: Some("a long intent ".repeat(20)),
1964 mode: ClaimMode::Exclusive,
1965 issue: Some(issue.into()),
1966 })
1967 .unwrap();
1968 }
1969 c.open_session(SessionInfo::named("s2", "test")).unwrap();
1970 c.close_session("s2").unwrap();
1971 c.request_review("s1", "feature/x".into(), vec![], None, None, None)
1972 .unwrap();
1973 let own = |tree: &PeerTree| -> RuntimeNode {
1974 tree.machines
1975 .iter()
1976 .flat_map(|m| m.runtimes.iter())
1977 .find(|r| r.this_runtime)
1978 .cloned()
1979 .expect("this runtime is in its own tree")
1980 };
1981
1982 let summary = own(&peer_tree(&c, &Query::default()));
1983 assert_eq!(
1984 summary
1985 .newest_claim
1986 .as_ref()
1987 .and_then(|b| b.issue.as_deref()),
1988 Some("#2"),
1989 "the newest live claim"
1990 );
1991 assert_eq!(summary.sessions.len(), 1, "only the active session");
1992 let s = &summary.sessions[0];
1993 assert_eq!(s.state, SessionState::Active);
1994 assert!(s.claims.is_empty(), "counted, not listed");
1995 assert_eq!(s.claims_held, 2);
1996 assert_eq!(s.reviews.len(), 1, "the review the session asked for");
1997
1998 let full = own(&peer_tree(
1999 &c,
2000 &Query {
2001 view: Some(View::Full),
2002 ..Query::default()
2003 },
2004 ));
2005 assert_eq!(full.sessions.len(), 2, "every session");
2006 let s1 = full
2007 .sessions
2008 .iter()
2009 .find(|s| s.state == SessionState::Active)
2010 .unwrap();
2011 assert_eq!(s1.claims.len(), 2, "every claim listed");
2012 assert_eq!(s1.claims_held, 2);
2013 }
2014}