1use std::collections::BTreeMap;
29use std::path::{Path, PathBuf};
30
31use schemars::JsonSchema;
32use serde::{Deserialize, Serialize};
33
34use crate::capability::handler::{CapabilityError, Context};
35use crate::capability::model::{
36 BenchmarkPolicy, CapabilityKind, CliExposure, Exposure, McpExposure, McpResource, Stability,
37 WaiverReason,
38};
39use crate::capability::module::ModuleDescriptor;
40use crate::capability::CachePolicy;
41use crate::git::{self, GitState};
42use crate::metadata::frontmatter;
43use crate::metadata::yaml;
44use crate::{capability, module};
45
46use super::continuity_transfer as transfer;
47use super::{get, mcp, post, Empty};
48
49fn cli(words: &[&str]) -> Option<CliExposure> {
50 Some(CliExposure {
51 path: words.iter().map(|w| w.to_string()).collect(),
52 })
53}
54
55pub const CONTINUITY_URI: &str = "majordomus://continuity";
57
58const STATE_DIR: &str = ".ai/local/state";
62
63#[derive(
68 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
69)]
70#[serde(rename_all = "snake_case")]
71pub enum Divergence {
72 Exact,
74 Advanced,
76 Diverged,
78 DifferentContext,
80 Unknown,
82}
83
84impl Divergence {
85 pub fn as_str(self) -> &'static str {
92 match self {
93 Divergence::Exact => "exact",
94 Divergence::Advanced => "advanced",
95 Divergence::Diverged => "diverged",
96 Divergence::DifferentContext => "different_context",
97 Divergence::Unknown => "unknown",
98 }
99 }
100
101 pub fn trustworthy(self) -> bool {
111 matches!(self, Divergence::Exact | Divergence::Advanced)
112 }
113}
114
115#[derive(
152 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
153)]
154#[serde(rename_all = "snake_case")]
155pub enum Freshness {
156 Fresh,
158 Aging,
160 Stale,
162 Unknown,
164 Invalid,
166}
167
168impl Freshness {
169 pub fn as_str(self) -> &'static str {
183 match self {
184 Freshness::Fresh => "fresh",
185 Freshness::Aging => "aging",
186 Freshness::Stale => "stale",
187 Freshness::Unknown => "unknown",
188 Freshness::Invalid => "invalid",
189 }
190 }
191
192 pub fn history(self) -> bool {
201 matches!(self, Freshness::Stale | Freshness::Invalid)
202 }
203}
204
205pub fn epoch_seconds(ts: &str) -> Option<i64> {
216 let b = ts.as_bytes();
217 if b.len() != 20 || b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[19] != b'Z' {
218 return None;
219 }
220 let n = |a: usize, z: usize| ts.get(a..z)?.parse::<i64>().ok();
221 let (y, mth, d) = (n(0, 4)?, n(5, 7)?, n(8, 10)?);
222 let (h, mi, sec) = (n(11, 13)?, n(14, 16)?, n(17, 19)?);
223 if !(1..=12).contains(&mth) || !(1..=31).contains(&d) || h > 23 || mi > 59 || sec > 60 {
224 return None;
225 }
226 let y = if mth <= 2 { y - 1 } else { y };
228 let era = y.div_euclid(400);
229 let yoe = y - era * 400;
230 let mp = if mth > 2 { mth - 3 } else { mth + 9 };
231 let doy = (153 * mp + 2) / 5 + d - 1;
232 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
233 let days = era * 146_097 + doe - 719_468;
234 Some(days * 86_400 + h * 3600 + mi * 60 + sec)
235}
236
237#[derive(Debug, Clone, Copy, Default)]
264pub struct Thresholds {
265 pub fresh_minutes: Option<i64>,
267 pub stale_minutes: Option<i64>,
269}
270
271impl Thresholds {
272 pub fn judge(self, created_at: &str, now: i64) -> (Freshness, Option<i64>, String) {
316 if created_at.is_empty() {
317 return (Freshness::Unknown, None, "no timestamp".into());
318 }
319 let Some(then) = epoch_seconds(created_at) else {
320 return (
321 Freshness::Invalid,
322 None,
323 format!("timestamp does not parse: {created_at}"),
324 );
325 };
326 let minutes = (now - then) / 60;
327 if minutes < 0 {
328 return (
329 Freshness::Invalid,
330 Some(minutes),
331 format!("timestamp is {} minute(s) in the future", -minutes),
332 );
333 }
334 let (Some(fresh), Some(stale)) = (self.fresh_minutes, self.stale_minutes) else {
335 return (
336 Freshness::Unknown,
337 Some(minutes),
338 "the policy declares no session.freshness thresholds".into(),
339 );
340 };
341 if minutes >= stale {
342 (
343 Freshness::Stale,
344 Some(minutes),
345 format!(
346 "{} old, past the {} this repository calls stale",
347 span(minutes),
348 span(stale)
349 ),
350 )
351 } else if minutes >= fresh {
352 (
353 Freshness::Aging,
354 Some(minutes),
355 format!(
356 "{} old, past the {} this repository calls fresh",
357 span(minutes),
358 span(fresh)
359 ),
360 )
361 } else {
362 (
363 Freshness::Fresh,
364 Some(minutes),
365 format!("{} old", span(minutes)),
366 )
367 }
368 }
369}
370
371pub fn span(minutes: i64) -> String {
380 if minutes < 90 {
381 format!("{minutes}m")
382 } else if minutes < 2880 {
383 format!("{}h", minutes / 60)
384 } else {
385 format!("{}d", minutes / 1440)
386 }
387}
388
389#[derive(
392 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
393)]
394#[serde(rename_all = "snake_case")]
395pub enum Match {
396 SameWorktreeSameBranch,
398 SameBranch,
400}
401
402#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
405pub struct Record {
406 pub path: String,
408 pub created_at: String,
410 pub task_id: String,
412 pub branch: String,
414 pub head: String,
416 pub working_tree: String,
418 pub matched: Match,
420 pub divergence: Divergence,
422 pub freshness: Freshness,
426 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub age_minutes: Option<i64>,
429 pub freshness_reason: String,
432 #[serde(default, skip_serializing_if = "String::is_empty")]
436 pub next_action: String,
437 #[serde(default, skip_serializing_if = "String::is_empty")]
441 pub next_action_withheld: String,
442}
443
444#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
452pub struct OpenSession {
453 pub session_id: String,
455 pub started_at: String,
457 #[serde(default, skip_serializing_if = "String::is_empty")]
459 pub owner: String,
460 #[serde(default, skip_serializing_if = "String::is_empty")]
462 pub worker: String,
463 #[serde(default, skip_serializing_if = "String::is_empty")]
465 pub provider: String,
466 #[serde(default, skip_serializing_if = "String::is_empty")]
470 pub provider_session: String,
471 #[serde(default, skip_serializing_if = "String::is_empty")]
473 pub branch: String,
474 #[serde(default, skip_serializing_if = "String::is_empty")]
476 pub start_head: String,
477 pub foreign: bool,
480}
481
482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
484pub struct ActiveTask {
485 pub id: String,
487 pub task: String,
489 pub profile: String,
491 pub outcome: String,
493 #[serde(default, skip_serializing_if = "Vec::is_empty")]
495 pub scope: Vec<String>,
496 #[serde(default, skip_serializing_if = "Vec::is_empty")]
504 pub requires: Vec<String>,
505 #[serde(default, skip_serializing_if = "String::is_empty")]
507 pub started_at: String,
508 #[serde(default, skip_serializing_if = "String::is_empty")]
510 pub head: String,
511 #[serde(default, skip_serializing_if = "String::is_empty")]
514 pub issue: String,
515 #[serde(default, skip_serializing_if = "String::is_empty")]
517 pub intent: String,
518 #[serde(default, skip_serializing_if = "String::is_empty")]
520 pub exemption: String,
521 #[serde(default, skip_serializing_if = "String::is_empty")]
523 pub exemption_because: String,
524 #[serde(default, skip_serializing_if = "String::is_empty")]
527 pub binding: String,
528 #[serde(default, skip_serializing_if = "String::is_empty")]
530 pub plan_revision: String,
531 #[serde(default, skip_serializing_if = "String::is_empty")]
533 pub evidence_standing: String,
534}
535
536#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
538pub struct Continuity {
539 pub present: bool,
546 pub worktree: String,
548 pub branch: String,
550 #[serde(default, skip_serializing_if = "String::is_empty")]
552 pub head: String,
553 #[serde(default, skip_serializing_if = "String::is_empty")]
555 pub working_tree: String,
556 #[serde(default, skip_serializing_if = "Option::is_none")]
558 pub session: Option<OpenSession>,
559 #[serde(default, skip_serializing_if = "Option::is_none")]
561 pub task: Option<ActiveTask>,
562 #[serde(default, skip_serializing_if = "Option::is_none")]
564 pub handover: Option<Record>,
565 #[serde(default, skip_serializing_if = "Option::is_none")]
567 pub checkpoint: Option<Record>,
568 #[serde(default, skip_serializing_if = "Vec::is_empty")]
571 pub blockers: Vec<String>,
572 pub tallies: BTreeMap<String, usize>,
574 #[serde(default, skip_serializing_if = "Vec::is_empty")]
577 pub findings: Vec<String>,
578}
579
580fn scalars(map: serde_json::Map<String, serde_json::Value>) -> BTreeMap<String, String> {
584 map.into_iter()
585 .filter_map(|(k, v)| yaml::scalar_string(&v).map(|s| (k, s)))
586 .collect()
587}
588
589fn front(path: &Path) -> Option<BTreeMap<String, String>> {
593 let text = std::fs::read_to_string(path).ok()?;
594 let split = frontmatter::split(&text).ok()?;
595 Some(scalars(yaml::parse_mapping(split.front?).ok()?))
596}
597
598pub(crate) fn document(path: &Path) -> Option<BTreeMap<String, String>> {
606 let text = std::fs::read_to_string(path).ok()?;
607 Some(scalars(yaml::parse_mapping(&text).ok()?))
608}
609
610fn section(path: &Path, want: &str) -> String {
613 let Ok(text) = std::fs::read_to_string(path) else {
614 return String::new();
615 };
616 let mut out: Vec<&str> = Vec::new();
617 let mut on = false;
618 for line in text.lines() {
619 if let Some(head) = line.strip_prefix("# ") {
620 on = head.trim_end() == want;
621 continue;
622 }
623 if on {
624 out.push(line);
625 }
626 }
627 while out.first().is_some_and(|l| l.trim().is_empty()) {
628 out.remove(0);
629 }
630 while out.last().is_some_and(|l| l.trim().is_empty()) {
631 out.pop();
632 }
633 out.join("\n")
634}
635
636pub(crate) fn divergence(root: &Path, theirs: &str, ours: Option<&str>) -> Divergence {
640 let Some(ours) = ours else {
641 return Divergence::Unknown;
642 };
643 if theirs == ours {
644 return Divergence::Exact;
645 }
646 if theirs.is_empty() {
647 return Divergence::Unknown;
648 }
649 match git::is_ancestor(root, theirs, ours) {
650 Some(true) => Divergence::Advanced,
651 Some(false) => Divergence::Diverged,
652 None => Divergence::Unknown,
653 }
654}
655
656pub(crate) fn resolve_record(
670 root: &Path,
671 dir: &Path,
672 branch: &str,
673 head: Option<&str>,
674 thresholds: Thresholds,
675 now: i64,
676) -> (Option<Record>, usize) {
677 resolve(root, dir, branch, head, thresholds, now)
678}
679
680fn resolve(
681 root: &Path,
682 dir: &Path,
683 branch: &str,
684 head: Option<&str>,
685 thresholds: Thresholds,
686 now: i64,
687) -> (Option<Record>, usize) {
688 let Ok(entries) = std::fs::read_dir(dir) else {
689 return (None, 0);
690 };
691 let worktree = root.to_string_lossy().to_string();
692 let mut best: Option<(u8, String, Record)> = None;
693 let mut skipped = 0usize;
694
695 let mut paths: Vec<PathBuf> = entries
696 .filter_map(|e| e.ok().map(|e| e.path()))
697 .filter(|p| p.extension().is_some_and(|x| x == "md"))
698 .collect();
699 paths.sort();
702
703 for path in paths {
704 let Some(f) = front(&path) else {
705 skipped += 1;
706 continue;
707 };
708 let (Some(created), Some(rhead)) = (f.get("created_at"), f.get("head")) else {
709 skipped += 1;
710 continue;
711 };
712 if f.get("schema_version").map(String::as_str) != Some("1") {
713 skipped += 1;
714 continue;
715 }
716 let rbranch = f.get("branch").cloned().unwrap_or_default();
717 let rworktree = f.get("worktree").cloned().unwrap_or_default();
718 let tier = if rworktree == worktree && rbranch == branch {
719 0u8
720 } else if branch != "DETACHED" && rbranch == branch {
721 1u8
722 } else {
723 continue;
724 };
725 let rel = path
726 .strip_prefix(root)
727 .unwrap_or(&path)
728 .to_string_lossy()
729 .to_string();
730 let record = Record {
731 path: rel,
732 created_at: created.clone(),
733 task_id: f.get("task_id").cloned().unwrap_or_else(|| "none".into()),
734 branch: rbranch,
735 head: rhead.clone(),
736 working_tree: f.get("working_tree").cloned().unwrap_or_default(),
737 matched: if tier == 0 {
738 Match::SameWorktreeSameBranch
739 } else {
740 Match::SameBranch
741 },
742 divergence: divergence(root, rhead, head),
743 freshness: Freshness::Unknown,
744 age_minutes: None,
745 freshness_reason: String::new(),
746 next_action: section(&path, "Next Action"),
747 next_action_withheld: String::new(),
748 };
749 let mut record = record;
754 let (f, age, why) = thresholds.judge(&record.created_at, now);
755 record.freshness = f;
756 record.age_minutes = age;
757 record.freshness_reason = why;
758 if f.history() && !record.next_action.is_empty() {
759 record.next_action_withheld = format!(
760 "the record is {}: {}. It is history, not an instruction; read it at the path above.",
761 f.as_str(),
762 record.freshness_reason
763 );
764 record.next_action.clear();
765 }
766 let key = created.clone();
767 let better = match &best {
768 None => true,
769 Some((btier, bkey, _)) => tier < *btier || (tier == *btier && key > *bkey),
770 };
771 if better {
772 best = Some((tier, key, record));
773 }
774 }
775 (best.map(|(_, _, r)| r), skipped)
776}
777
778fn count(dir: &Path) -> usize {
780 std::fs::read_dir(dir)
781 .map(|d| {
782 d.filter_map(|e| e.ok())
783 .filter(|e| e.path().extension().is_some_and(|x| x == "md"))
784 .count()
785 })
786 .unwrap_or(0)
787}
788
789fn blockers(path: &Path) -> Vec<String> {
799 let Ok(text) = std::fs::read_to_string(path) else {
800 return Vec::new();
801 };
802 let mut out = Vec::new();
803 let mut in_comment = false;
804 for line in text.lines() {
805 if line.contains("<!--") {
806 in_comment = true;
807 }
808 if in_comment {
809 if line.contains("-->") {
810 in_comment = false;
811 }
812 continue;
813 }
814 if let Some(rest) = line.strip_prefix("- [unresolved] ") {
815 out.push(rest.trim().to_string());
816 }
817 }
818 out
819}
820
821pub(crate) fn thresholds_of(ctx: &Context) -> Thresholds {
833 let root = PathBuf::from(&ctx.index.repository.root);
834 let Ok(repo) = crate::repository::Repository::open(&root) else {
835 return Thresholds::default();
836 };
837 let Ok(loaded) = crate::policy::LoadedPolicy::load(&repo) else {
838 return Thresholds::default();
839 };
840 Thresholds {
841 fresh_minutes: loaded.policy.session.freshness.fresh_minutes,
842 stale_minutes: loaded.policy.session.freshness.stale_minutes,
843 }
844}
845
846fn state(ctx: &Context, _: Empty) -> Result<Continuity, CapabilityError> {
847 let root = PathBuf::from(&ctx.index.repository.root);
848 let dir = root.join(STATE_DIR);
849
850 let (branch, head, working_tree) = match &ctx.index.repository.git {
851 GitState::Available(info) => (
852 info.branch.clone().unwrap_or_else(|| "DETACHED".into()),
853 info.head.clone(),
854 info.working_tree.clone(),
855 ),
856 GitState::Unavailable { .. } => ("DETACHED".into(), None, String::new()),
857 };
858
859 let mut findings = Vec::new();
860 let mut skipped = 0usize;
861
862 let session = match document(&dir.join("session-current.yaml")) {
864 Some(f) => {
865 let sworktree = f.get("worktree").cloned().unwrap_or_default();
866 let foreign = !sworktree.is_empty() && sworktree != root.to_string_lossy();
867 if foreign {
868 findings.push(format!(
869 "the open session record here belongs to {sworktree}, not this checkout; nothing about it is about your work"
870 ));
871 }
872 f.get("session_id").map(|id| OpenSession {
873 session_id: id.clone(),
874 started_at: f.get("started_at").cloned().unwrap_or_default(),
875 owner: f.get("owner").cloned().unwrap_or_default(),
876 worker: f.get("worker").cloned().unwrap_or_default(),
877 provider: f.get("provider").cloned().unwrap_or_default(),
878 provider_session: f.get("provider_session").cloned().unwrap_or_default(),
879 branch: f.get("branch").cloned().unwrap_or_default(),
880 start_head: f.get("start_head").cloned().unwrap_or_default(),
881 foreign,
882 })
883 }
884 None => None,
885 };
886
887 let task = read_task(&dir.join("current.yaml"));
890
891 let thresholds = thresholds_of(ctx);
896 let now = std::time::SystemTime::now()
897 .duration_since(std::time::UNIX_EPOCH)
898 .map(|d| d.as_secs() as i64)
899 .unwrap_or(0);
900 let (handover, s1) = resolve(
901 &root,
902 &dir.join("handovers"),
903 &branch,
904 head.as_deref(),
905 thresholds,
906 now,
907 );
908 let (checkpoint, s2) = resolve(
909 &root,
910 &dir.join("checkpoints"),
911 &branch,
912 head.as_deref(),
913 thresholds,
914 now,
915 );
916 skipped += s1 + s2;
917 if skipped > 0 {
918 findings.push(format!(
919 "{skipped} file(s) under {STATE_DIR} could not be read as records and were skipped; run `majordomus doctor`"
920 ));
921 }
922 for (what, r) in [("handover", &handover), ("checkpoint", &checkpoint)] {
923 if let Some(r) = r {
924 if !r.divergence.trustworthy() {
925 findings.push(format!(
926 "the resolved {what} is {}: it was written at {} and that commit is not in this history; trust git over it",
927 r.divergence.as_str(),
928 &r.head[..7.min(r.head.len())]
929 ));
930 }
931 if r.freshness.history() {
936 findings.push(format!(
937 "the resolved {what} is {}: {}. It is context, not an instruction; what it said to do next is not offered as current.",
938 r.freshness.as_str(),
939 r.freshness_reason
940 ));
941 }
942 }
943 }
944
945 let blockers = blockers(&dir.join("open-questions.md"));
946 if !blockers.is_empty() {
947 findings.push(format!(
948 "{} unresolved question(s) on this branch; every one refuses `majordomus finish --outcome completed`",
949 blockers.len()
950 ));
951 }
952
953 let mut tallies = BTreeMap::new();
954 tallies.insert("handovers".to_string(), count(&dir.join("handovers")));
955 tallies.insert("checkpoints".to_string(), count(&dir.join("checkpoints")));
956 tallies.insert("blockers".to_string(), blockers.len());
961 tallies.insert(
962 "ledger_lines".to_string(),
963 std::fs::read_to_string(dir.join("ledger.jsonl"))
964 .map(|t| t.lines().filter(|l| !l.trim().is_empty()).count())
965 .unwrap_or(0),
966 );
967
968 let present = task.is_some() || session.is_some() || tallies.values().any(|n| *n > 0);
969
970 Ok(Continuity {
971 present,
972 worktree: root.to_string_lossy().to_string(),
973 branch,
974 head: head.unwrap_or_default(),
975 working_tree,
976 session,
977 task,
978 handover,
979 checkpoint,
980 blockers,
981 tallies,
982 findings,
983 })
984}
985
986pub(crate) fn read_task(path: &Path) -> Option<ActiveTask> {
994 let text = std::fs::read_to_string(path).ok()?;
995 let map = yaml::parse_mapping(&text).ok()?;
996 let s = |k: &str| map.get(k).and_then(yaml::scalar_string).unwrap_or_default();
997 let id = s("id");
998 if id.is_empty() {
999 return None;
1000 }
1001 let list = |k: &str| -> Vec<String> {
1002 map.get(k)
1003 .and_then(|v| v.as_array())
1004 .map(|a| a.iter().filter_map(yaml::scalar_string).collect())
1005 .unwrap_or_default()
1006 };
1007 let scope = list("scope");
1008 let requires = list("requires");
1009 Some(ActiveTask {
1010 id,
1011 task: s("task"),
1012 profile: s("profile"),
1013 outcome: s("outcome"),
1014 scope,
1015 requires,
1016 started_at: s("started_at"),
1017 head: s("head"),
1018 issue: s("issue"),
1019 intent: s("intent"),
1020 exemption: s("exemption"),
1021 exemption_because: s("exemption_because"),
1022 binding: s("binding"),
1023 plan_revision: s("plan_revision"),
1024 evidence_standing: s("evidence_standing"),
1025 })
1026}
1027
1028pub fn module() -> ModuleDescriptor {
1030 module! {
1031 id: "continuity",
1032 title: "Continuity",
1033 description: "What this checkout's lifecycle is holding: the open episode, the record the next worker would resume from with the label that says how far to trust it, the newest progress note, and what is blocking acceptance. Read from the local half of the layer, which this process serves to the worker in front of it and never publishes.",
1034 stability: Stability::BehaviorallyVerified,
1035 capabilities: [
1036 capability! {
1037 id: "continuity.state",
1038 title: "What the lifecycle is holding",
1039 description: "The open episode, the active task, the handover and checkpoint that resolve for this worktree and branch, each with its divergence label, the unresolved questions that refuse completion, and the record tallies. Selection is two-tiered and never repository-wide: a record from an unrelated worktree or branch is not offered, because a briefing that is quietly about somebody else is worse than none. Absence is reported as absence.",
1040 input: Empty,
1041 output: Continuity,
1042 stability: Stability::BehaviorallyVerified,
1043 exposure: Exposure {
1044 mcp: Some(McpExposure {
1045 tool: Some("majordomus_continuity".into()),
1046 resource: Some(McpResource { uri: CONTINUITY_URI.into(), name: "continuity".into() }),
1047 }),
1048 http: get("/api/v1/continuity"),
1049 cli: None,
1050 },
1051 tags: ["continuity", "session", "handover"],
1052 cache: CachePolicy::Process { max_entries: 2, ttl_seconds: Some(2) },
1056 handler: state,
1057 },
1058 capability! {
1059 id: "continuity.status",
1060 title: "Where this device stands in the work published from every device",
1061 description: "This device and repository identity, the record this checkout continues on its branch, how the local continuity store stands towards the remote's (from the refs alone, no network), every line of work with its heads, the handovers other devices published that this checkout could resume, and every refused record or broken lineage as a diagnostic. A diverged line — the same work continued twice — is reported; separate lines of work on separate devices are not a conflict.",
1062 input: Empty,
1063 output: crate::continuity::Status,
1064 stability: Stability::Experimental,
1065 exposure: Exposure {
1066 mcp: mcp("majordomus_continuity_status"),
1067 http: get("/api/v1/continuity/status"),
1068 cli: cli(&["continuity", "status"]),
1069 },
1070 tags: ["continuity", "handover", "devices"],
1071 cache: CachePolicy::Disabled,
1072 handler: transfer::status,
1073 },
1074 capability! {
1075 id: "continuity.device",
1076 kind: CapabilityKind::Command,
1077 title: "This device's identity, and its label",
1078 description: "The device a published handover names: the mesh node key (created when this device has none, in the user's state directory and never in a repository), its node id, its public key — what a trust list admits — and the label a person reads. With a label, renames the device; the key and every signature it made are unchanged.",
1079 input: transfer::DeviceInput,
1080 output: crate::continuity::DeviceView,
1081 stability: Stability::Experimental,
1082 exposure: Exposure {
1083 mcp: mcp("majordomus_continuity_device"),
1084 http: post("/api/v1/continuity/device"),
1085 cli: cli(&["continuity", "device"]),
1086 },
1087 tags: ["continuity", "devices"],
1088 cache: CachePolicy::Disabled,
1089 benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::Destructive },
1090 handler: transfer::device,
1091 },
1092 capability! {
1093 id: "continuity.records",
1094 title: "Every published handover the local store admits",
1095 description: "Every record of refs/majordomus/continuity that passed admission — schema, id, signature, repository identity, portability — by line, with each line's heads, and a diagnostic for every file refused and every lineage defect.",
1096 input: Empty,
1097 output: crate::continuity::Records,
1098 stability: Stability::Experimental,
1099 exposure: Exposure {
1100 mcp: mcp("majordomus_continuity_records"),
1101 http: get("/api/v1/continuity/records"),
1102 cli: cli(&["continuity", "records"]),
1103 },
1104 tags: ["continuity", "handover", "devices"],
1105 cache: CachePolicy::Disabled,
1106 handler: transfer::records,
1107 },
1108 capability! {
1109 id: "continuity.plan",
1110 title: "Whether and how a published handover can be resumed here",
1111 description: "Writes nothing. Chooses the record (named, or the one other device's handover this checkout has not resumed, on this branch first) and decides: is its signer trusted, does it diverge from the record this checkout stands on, does the local source hold the commit and branch it was written against, was it written in a dirty tree whose uncommitted files never reached a commit, may the other device still be working. The verdict is ready, ready_with_warnings, requires_source_update, conflict, choose_record, nothing_to_resume or refused, with blockers, warnings, the commands that resolve them (recommended, never run), what a resume restores and what this checkout recomputes for itself.",
1112 input: transfer::RecordInput,
1113 output: crate::continuity::ResumePlan,
1114 stability: Stability::Experimental,
1115 exposure: Exposure {
1116 mcp: mcp("majordomus_continuity_plan"),
1117 http: get("/api/v1/continuity/plan"),
1118 cli: cli(&["continuity", "plan"]),
1119 },
1120 tags: ["continuity", "handover", "devices"],
1121 cache: CachePolicy::Disabled,
1122 handler: transfer::plan,
1123 },
1124 capability! {
1125 id: "continuity.publish",
1126 kind: CapabilityKind::Command,
1127 title: "Publish this checkout's newest handover for another machine",
1128 description: "Projects the newest handover record into a portable record — the handover body, the repository and device identity, the episode, the source state (branch, commit, and for a dirty tree the changed paths and a fingerprint, never their content), the task and its decisions, and the record it continues — refuses it when any value carries a credential, a secret environment value or a path of this machine's disk, signs it with the device's mesh key and adds it to refs/majordomus/continuity. Touches no branch, index or working tree, and no network: a sync publishes it.",
1129 input: transfer::PublishInput,
1130 output: crate::continuity::Published,
1131 stability: Stability::Experimental,
1132 exposure: Exposure {
1133 mcp: mcp("majordomus_continuity_publish"),
1134 http: post("/api/v1/continuity/publish"),
1135 cli: cli(&["continuity", "publish"]),
1136 },
1137 tags: ["continuity", "handover", "devices"],
1138 cache: CachePolicy::Disabled,
1139 benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::Destructive },
1140 handler: transfer::publish,
1141 }
1142 .writes_repository(),
1143 capability! {
1144 id: "continuity.sync",
1145 kind: CapabilityKind::Command,
1146 title: "Exchange published handovers with a git remote",
1147 description: "Fetches the remote's refs/majordomus/continuity, merges it into the local store as the union of both (records are content-addressed, so a name holding different bytes is reported and the local copy kept), and pushes the result, never forced. Reports each line as equal, remote_newer, local_newer, diverged, local_only or remote_only. An unreachable remote changes nothing and leaves what is pending pending.",
1148 input: transfer::SyncInput,
1149 output: crate::continuity::Synced,
1150 stability: Stability::Experimental,
1151 exposure: Exposure {
1152 mcp: mcp("majordomus_continuity_sync"),
1153 http: post("/api/v1/continuity/sync"),
1154 cli: cli(&["continuity", "sync"]),
1155 },
1156 tags: ["continuity", "handover", "devices"],
1157 cache: CachePolicy::Disabled,
1158 benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::ExternalDependency },
1159 handler: transfer::sync,
1160 }
1161 .writes_repository(),
1162 capability! {
1163 id: "continuity.resume",
1164 kind: CapabilityKind::Command,
1165 title: "Resume a handover another machine published",
1166 description: "Plans exactly as continuity.plan does and acts only on a ready or ready_with_warnings plan: writes the handover into this checkout's handovers (where handover --resolve and the session briefing find it), appends the task's decisions to the decision log once each, and records the record as the one this checkout continues, so that its next publication extends the same line. Runs nothing the record says; the start command for the task is returned as a recommendation.",
1167 input: transfer::RecordInput,
1168 output: crate::continuity::Resumed,
1169 stability: Stability::Experimental,
1170 exposure: Exposure {
1171 mcp: mcp("majordomus_continuity_resume"),
1172 http: post("/api/v1/continuity/resume"),
1173 cli: cli(&["continuity", "resume"]),
1174 },
1175 tags: ["continuity", "handover", "devices"],
1176 cache: CachePolicy::Disabled,
1177 benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::Destructive },
1178 handler: transfer::resume,
1179 }
1180 .writes_repository(),
1181 ],
1182 }
1183}
1184
1185#[cfg(test)]
1186mod tests {
1187 use super::*;
1188 use std::process::Command;
1189
1190 struct Repo(tempfile::TempDir);
1194 impl Repo {
1195 fn new() -> Self {
1196 let dir = tempfile::tempdir().expect("a temporary directory");
1197 let r = Repo(dir);
1198 r.git(&["init", "-q", "."]);
1199 r.git(&["config", "user.email", "t@example.com"]);
1200 r.git(&["config", "user.name", "t"]);
1201 r.git(&["commit", "-q", "--allow-empty", "-m", "one"]);
1202 r
1203 }
1204 fn root(&self) -> &Path {
1205 self.0.path()
1206 }
1207 fn git(&self, args: &[&str]) -> String {
1208 let out = Command::new("git")
1209 .arg("-C")
1210 .arg(self.root())
1211 .args(args)
1212 .output()
1213 .expect("git");
1214 String::from_utf8_lossy(&out.stdout).trim().to_string()
1215 }
1216 fn head(&self) -> String {
1217 self.git(&["rev-parse", "HEAD"])
1218 }
1219 fn write(&self, rel: &str, body: &str) -> PathBuf {
1220 let p = self.root().join(rel);
1221 std::fs::create_dir_all(p.parent().expect("a parent")).expect("mkdir");
1222 std::fs::write(&p, body).expect("write");
1223 p
1224 }
1225 }
1226
1227 fn record(created: &str, head: &str, branch: &str, worktree: &str, next: &str) -> String {
1229 format!(
1230 "---\nschema_version: 1\ncreated_at: {created}\ntask_id: t-1\nprofile: implementation\n\
1231 repository_id: x\nworktree: {worktree}\nbranch: {branch}\nhead: {head}\n\
1232 working_tree: clean\nchanged_files:\n---\n\n# Next Action\n\n{next}\n"
1233 )
1234 }
1235
1236 #[test]
1241 fn every_divergence_word_is_the_shell_tools_and_says_whether_it_may_be_trusted() {
1242 let all = [
1243 (Divergence::Exact, "exact", true),
1244 (Divergence::Advanced, "advanced", true),
1245 (Divergence::Diverged, "diverged", false),
1246 (Divergence::DifferentContext, "different_context", false),
1247 (Divergence::Unknown, "unknown", false),
1248 ];
1249 for (d, word, trust) in all {
1250 assert_eq!(d.as_str(), word);
1251 assert_eq!(d.trustworthy(), trust, "{word}");
1252 assert_eq!(
1253 serde_json::to_value(d).expect("json"),
1254 serde_json::json!(word)
1255 );
1256 }
1257 assert_eq!(
1259 serde_json::to_value(Match::SameWorktreeSameBranch).expect("json"),
1260 serde_json::json!("same_worktree_same_branch")
1261 );
1262 assert_eq!(
1263 serde_json::to_value(Match::SameBranch).expect("json"),
1264 serde_json::json!("same_branch")
1265 );
1266 }
1267
1268 #[test]
1274 fn divergence_answers_unknown_rather_than_guessing_when_git_cannot_say() {
1275 let repo = Repo::new();
1276 let first = repo.head();
1277 repo.git(&["commit", "-q", "--allow-empty", "-m", "two"]);
1278 let second = repo.head();
1279
1280 assert_eq!(
1281 divergence(repo.root(), &first, Some(&first)),
1282 Divergence::Exact
1283 );
1284 assert_eq!(
1285 divergence(repo.root(), &first, Some(&second)),
1286 Divergence::Advanced
1287 );
1288 assert_eq!(
1290 divergence(
1291 repo.root(),
1292 "0000000000000000000000000000000000000000",
1293 Some(&second)
1294 ),
1295 Divergence::Diverged
1296 );
1297 assert_eq!(divergence(repo.root(), &first, None), Divergence::Unknown);
1299 assert_eq!(
1301 divergence(repo.root(), "", Some(&second)),
1302 Divergence::Unknown
1303 );
1304 }
1305
1306 #[test]
1312 fn a_file_that_is_not_a_record_is_none_rather_than_an_error() {
1313 let repo = Repo::new();
1314 assert!(front(&repo.root().join("nothing.md")).is_none());
1315 assert!(front(&repo.write("plain.md", "just prose, no front matter\n")).is_none());
1316 assert!(front(&repo.write("broken.md", "---\n: : :\n---\nbody\n")).is_none());
1317 let good = repo.write(
1318 "good.md",
1319 &record("2026-01-01T00:00:00Z", "abc", "main", "/w", "Go"),
1320 );
1321 let f = front(&good).expect("a record");
1322 assert_eq!(f.get("branch").map(String::as_str), Some("main"));
1323 assert!(!f.contains_key("changed_files"));
1325 }
1326
1327 #[test]
1331 fn a_fenceless_document_is_read_whole_and_a_missing_one_is_none() {
1332 let repo = Repo::new();
1333 assert!(document(&repo.root().join("nothing.yaml")).is_none());
1334 let p = repo.write(
1335 "session.yaml",
1336 "session_id: s-1\nstarted_at: t\nowner: \"me\"\n",
1337 );
1338 let d = document(&p).expect("a document");
1339 assert_eq!(d.get("session_id").map(String::as_str), Some("s-1"));
1340 assert_eq!(d.get("owner").map(String::as_str), Some("me"));
1341 assert!(document(&repo.write("bad.yaml", "\t: [unclosed\n")).is_none());
1343 }
1344
1345 #[test]
1350 fn one_section_is_lifted_whole_and_trimmed_at_both_ends() {
1351 let repo = Repo::new();
1352 assert_eq!(section(&repo.root().join("nothing.md"), "Next Action"), "");
1353 let p = repo.write(
1354 "r.md",
1355 "# Objective\n\nSomething.\n\n# Next Action\n\n\nDo the thing.\nThen the other.\n\n\n# After\n\nNo.\n",
1356 );
1357 assert_eq!(section(&p, "Next Action"), "Do the thing.\nThen the other.");
1358 assert_eq!(section(&p, "Objective"), "Something.");
1359 assert_eq!(section(&p, "Nothing Like This"), "");
1360 let q = repo.write("s.md", "# Next Action \n\nHere.\n");
1362 assert_eq!(section(&q, "Next Action"), "Here.");
1363 let e = repo.write("e.md", "# Next Action\n\n\n\n");
1365 assert_eq!(section(&e, "Next Action"), "");
1366 }
1367
1368 #[test]
1373 fn only_unresolved_entries_outside_the_comment_are_blockers() {
1374 let repo = Repo::new();
1375 assert!(blockers(&repo.root().join("nothing.md")).is_empty());
1376 let p = repo.write(
1377 "q.md",
1378 "# Open questions\n\n<!--\n- [unresolved] the template's own example\n-->\n\n\
1379 - [unresolved] can we ship it\n- [resolved] we could not\n- [unresolved] spaced \n\
1380 not a list line at all\n",
1381 );
1382 assert_eq!(
1383 blockers(&p),
1384 vec!["can we ship it".to_string(), "spaced".to_string()]
1385 );
1386 let q = repo.write("q2.md", "<!-- a note -->\n- [unresolved] still counted\n");
1388 assert_eq!(blockers(&q), vec!["still counted".to_string()]);
1389 }
1390
1391 #[test]
1394 fn only_markdown_is_counted_and_a_missing_directory_is_zero() {
1395 let repo = Repo::new();
1396 assert_eq!(count(&repo.root().join("nowhere")), 0);
1397 repo.write("d/a.md", "x");
1398 repo.write("d/b.md", "x");
1399 repo.write("d/c.yaml", "x");
1400 repo.write("d/.tmp.abcdef", "x");
1401 assert_eq!(count(&repo.root().join("d")), 2);
1402 }
1403
1404 fn unjudged() -> (Thresholds, i64) {
1411 (
1412 Thresholds::default(),
1413 epoch_seconds("2099-01-01T00:00:00Z").expect("a parseable instant"),
1414 )
1415 }
1416
1417 #[test]
1422 fn the_nearer_tier_wins_and_the_newer_record_wins_inside_one() {
1423 let (t, now) = unjudged();
1424 let repo = Repo::new();
1425 let head = repo.head();
1426 let root = repo.root().to_string_lossy().to_string();
1427 let dir = repo.root().join("h");
1428
1429 let (none, skipped) = resolve(repo.root(), &dir, "main", Some(&head), t, now);
1431 assert!(none.is_none() && skipped == 0);
1432
1433 repo.write(
1434 "h/elsewhere.md",
1435 &record("2026-01-03T00:00:00Z", &head, "main", "/other", "Theirs"),
1436 );
1437 let (r, _) = resolve(repo.root(), &dir, "main", Some(&head), t, now);
1438 let r = r.expect("the other worktree's record, for want of one here");
1439 assert_eq!(r.matched, Match::SameBranch);
1440 assert_eq!(r.next_action, "Theirs");
1441
1442 repo.write(
1444 "h/mine.md",
1445 &record("2026-01-01T00:00:00Z", &head, "main", &root, "Mine"),
1446 );
1447 let (r, _) = resolve(repo.root(), &dir, "main", Some(&head), t, now);
1448 let r = r.expect("this worktree's record");
1449 assert_eq!(r.matched, Match::SameWorktreeSameBranch);
1450 assert_eq!(r.next_action, "Mine");
1451 assert_eq!(r.divergence, Divergence::Exact);
1452 assert_eq!(r.task_id, "t-1");
1453 assert_eq!(r.working_tree, "clean");
1454 assert!(
1455 r.path.starts_with("h/"),
1456 "the path is repository-relative: {}",
1457 r.path
1458 );
1459
1460 repo.write(
1462 "h/newer.md",
1463 &record("2026-01-02T00:00:00Z", &head, "main", &root, "Newer"),
1464 );
1465 let (r, _) = resolve(repo.root(), &dir, "main", Some(&head), t, now);
1466 assert_eq!(r.expect("a record").next_action, "Newer");
1467
1468 repo.write(
1470 "h/other-branch.md",
1471 &record("2026-09-09T00:00:00Z", &head, "topic", &root, "No"),
1472 );
1473 let (r, _) = resolve(repo.root(), &dir, "main", Some(&head), t, now);
1474 assert_eq!(r.expect("a record").next_action, "Newer");
1475 }
1476
1477 #[test]
1480 fn a_detached_checkout_is_offered_no_other_worktrees_record() {
1481 let (t, now) = unjudged();
1482 let repo = Repo::new();
1483 let head = repo.head();
1484 repo.write(
1485 "h/theirs.md",
1486 &record("2026-01-01T00:00:00Z", &head, "DETACHED", "/other", "No"),
1487 );
1488 let (r, skipped) = resolve(
1489 repo.root(),
1490 &repo.root().join("h"),
1491 "DETACHED",
1492 Some(&head),
1493 t,
1494 now,
1495 );
1496 assert!(
1497 r.is_none(),
1498 "a detached checkout was handed another worktree's record"
1499 );
1500 assert_eq!(
1501 skipped, 0,
1502 "a record that does not match is not a record that is broken"
1503 );
1504 }
1505
1506 #[test]
1512 fn every_unreadable_file_is_counted_and_a_future_version_is_one_of_them() {
1513 let (t, now) = unjudged();
1514 let repo = Repo::new();
1515 let head = repo.head();
1516 let root = repo.root().to_string_lossy().to_string();
1517 repo.write("h/prose.md", "no front matter here\n");
1518 repo.write(
1519 "h/no-created.md",
1520 "---\nschema_version: 1\nhead: abc\n---\n",
1521 );
1522 repo.write(
1523 "h/no-head.md",
1524 "---\nschema_version: 1\ncreated_at: x\n---\n",
1525 );
1526 repo.write(
1527 "h/future.md",
1528 &record("2026-01-01T00:00:00Z", &head, "main", &root, "No")
1529 .replace("schema_version: 1", "schema_version: 2"),
1530 );
1531 repo.write("h/README.md.yaml", "x\n");
1533 let (r, skipped) = resolve(
1534 repo.root(),
1535 &repo.root().join("h"),
1536 "main",
1537 Some(&head),
1538 t,
1539 now,
1540 );
1541 assert!(r.is_none(), "an unreadable file was offered as a record");
1542 assert_eq!(skipped, 4);
1543 }
1544
1545 #[test]
1550 fn a_record_with_no_task_says_none_rather_than_leaving_the_field_empty() {
1551 let (t, now) = unjudged();
1552 let repo = Repo::new();
1553 let head = repo.head();
1554 let root = repo.root().to_string_lossy().to_string();
1555 let body = record("2026-01-01T00:00:00Z", &head, "main", &root, "Go")
1556 .replace("task_id: t-1\n", "");
1557 repo.write("h/no-task.md", &body);
1558 let (r, skipped) = resolve(
1559 repo.root(),
1560 &repo.root().join("h"),
1561 "main",
1562 Some(&head),
1563 t,
1564 now,
1565 );
1566 let r = r.expect("a record without a task is still a record");
1567 assert_eq!(r.task_id, "none");
1568 assert_eq!(skipped, 0);
1569 }
1570
1571 #[test]
1575 fn two_records_in_one_second_resolve_the_same_way_every_time() {
1576 let (t, now) = unjudged();
1577 let repo = Repo::new();
1578 let head = repo.head();
1579 let root = repo.root().to_string_lossy().to_string();
1580 for name in ["b", "a", "c"] {
1581 repo.write(
1582 &format!("h/{name}.md"),
1583 &record("2026-01-01T00:00:00Z", &head, "main", &root, name),
1584 );
1585 }
1586 let first = resolve(
1587 repo.root(),
1588 &repo.root().join("h"),
1589 "main",
1590 Some(&head),
1591 t,
1592 now,
1593 )
1594 .0;
1595 for _ in 0..5 {
1596 let again = resolve(
1597 repo.root(),
1598 &repo.root().join("h"),
1599 "main",
1600 Some(&head),
1601 t,
1602 now,
1603 )
1604 .0;
1605 assert_eq!(first, again);
1606 }
1607 }
1608
1609 #[test]
1616 fn a_task_is_read_with_its_lists_and_a_file_without_an_id_is_no_task() {
1617 let repo = Repo::new();
1618 assert!(read_task(&repo.root().join("nothing.yaml")).is_none());
1619 assert!(read_task(&repo.write("empty.yaml", "")).is_none());
1620 assert!(read_task(&repo.write("broken.yaml", "\t: [unclosed\n")).is_none());
1621 assert!(read_task(&repo.write("idless.yaml", "task: something\nprofile: x\n")).is_none());
1622 let p = repo.write(
1623 "current.yaml",
1624 "id: t-1\ntask: do it\nprofile: implementation\noutcome: active\n\
1625 started_at: 2026-01-01T00:00:00Z\nhead: abc\nscope:\n - lib\n - test\n\
1626 requires:\n - committed\n",
1627 );
1628 let t = read_task(&p).expect("a task");
1629 assert_eq!(t.id, "t-1");
1630 assert_eq!(t.outcome, "active");
1631 assert_eq!(t.scope, vec!["lib".to_string(), "test".to_string()]);
1632 assert_eq!(t.requires, vec!["committed".to_string()]);
1633 assert_eq!(t.head, "abc");
1634 let q = repo.write("bare.yaml", "id: t-2\n");
1636 let t = read_task(&q).expect("a task");
1637 assert!(t.scope.is_empty() && t.requires.is_empty() && t.profile.is_empty());
1638 }
1639
1640 #[test]
1643 fn freshness_is_decided_at_the_thresholds_the_policy_declares() {
1644 let t = Thresholds {
1645 fresh_minutes: Some(720),
1646 stale_minutes: Some(2880),
1647 };
1648 let now = epoch_seconds("2026-06-15T12:00:00Z").expect("a parseable instant");
1649 let at = |minutes: i64| {
1650 let secs = now - minutes * 60;
1651 crate::peers::rfc3339(
1655 std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs as u64),
1656 )
1657 };
1658 let verdict = |minutes: i64| t.judge(&at(minutes), now).0;
1659
1660 assert_eq!(verdict(1), Freshness::Fresh);
1661 assert_eq!(verdict(719), Freshness::Fresh);
1662 assert_eq!(verdict(720), Freshness::Aging);
1663 assert_eq!(verdict(721), Freshness::Aging);
1664 assert_eq!(verdict(2879), Freshness::Aging);
1665 assert_eq!(verdict(2880), Freshness::Stale);
1666 assert_eq!(verdict(2881), Freshness::Stale);
1667 assert_eq!(verdict(6 * 24 * 60), Freshness::Stale);
1669 }
1670
1671 #[test]
1674 fn every_verdict_says_why() {
1675 let t = Thresholds {
1676 fresh_minutes: Some(720),
1677 stale_minutes: Some(2880),
1678 };
1679 let now = epoch_seconds("2026-06-15T12:00:00Z").expect("a parseable instant");
1680 for ts in [
1681 "2026-06-15T11:59:00Z",
1682 "2026-06-14T00:00:00Z",
1683 "2026-06-01T00:00:00Z",
1684 "2026-06-15T13:00:00Z",
1685 "not a timestamp",
1686 "",
1687 ] {
1688 let (_, _, why) = t.judge(ts, now);
1689 assert!(!why.is_empty(), "no reason given for {ts:?}");
1690 }
1691 }
1692
1693 #[test]
1698 fn what_cannot_be_judged_is_reported_as_itself() {
1699 let t = Thresholds {
1700 fresh_minutes: Some(720),
1701 stale_minutes: Some(2880),
1702 };
1703 let now = epoch_seconds("2026-06-15T12:00:00Z").expect("a parseable instant");
1704
1705 assert_eq!(t.judge("", now).0, Freshness::Unknown);
1706 assert_eq!(t.judge("not a timestamp", now).0, Freshness::Invalid);
1707
1708 let (f, _, why) = t.judge("2026-06-15T13:00:00Z", now);
1709 assert_eq!(f, Freshness::Invalid);
1710 assert!(
1711 why.contains("future"),
1712 "a future timestamp did not say so: {why}"
1713 );
1714
1715 let none = Thresholds::default();
1716 let (f, age, why) = none.judge("2026-06-01T00:00:00Z", now);
1717 assert_eq!(f, Freshness::Unknown);
1718 assert_eq!(
1720 age,
1721 Some(14 * 1440 + 720),
1722 "the age is known even when the verdict is not"
1723 );
1724 assert!(
1725 why.contains("session.freshness"),
1726 "the missing key is not named: {why}"
1727 );
1728 }
1729
1730 #[test]
1733 fn only_stale_and_invalid_are_history() {
1734 assert!(Freshness::Stale.history());
1735 assert!(Freshness::Invalid.history());
1736 assert!(!Freshness::Fresh.history());
1737 assert!(!Freshness::Aging.history());
1738 assert!(!Freshness::Unknown.history());
1739 }
1740
1741 #[test]
1744 fn the_parser_inverts_the_formatter() {
1745 for secs in [0i64, 1_788_000_000, 1_757_000_000, 253_370_764_800] {
1746 let text = crate::peers::rfc3339(
1747 std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs as u64),
1748 );
1749 assert_eq!(
1750 epoch_seconds(&text),
1751 Some(secs),
1752 "round trip failed for {text}"
1753 );
1754 }
1755 for bad in [
1756 "",
1757 "2026-09-05",
1758 "2026-09-05T12:34:56",
1759 "2026-09-05T12:34:56+01:00",
1760 "2026-13-05T12:34:56Z",
1761 "2026-09-32T12:34:56Z",
1762 "2026-09-05T24:34:56Z",
1763 "xxxx-09-05T12:34:56Z",
1764 ] {
1765 assert_eq!(epoch_seconds(bad), None, "{bad:?} was accepted");
1766 }
1767 }
1768
1769 #[test]
1774 fn the_declaration_yields_the_identity_and_the_projections_it_claims() {
1775 let m = module();
1776 assert_eq!(m.id.as_str(), "continuity");
1777 let ids: Vec<&str> = m
1778 .capabilities
1779 .iter()
1780 .map(|e| e.capability.id.as_str())
1781 .collect();
1782 assert_eq!(
1783 ids,
1784 [
1785 "continuity.state",
1786 "continuity.status",
1787 "continuity.device",
1788 "continuity.records",
1789 "continuity.plan",
1790 "continuity.publish",
1791 "continuity.sync",
1792 "continuity.resume",
1793 ]
1794 );
1795 for e in &m.capabilities[1..] {
1798 let c = &e.capability;
1799 let cli = c.exposure.cli.as_ref().expect("a command-line projection");
1800 assert_eq!(cli.path[0], "continuity");
1801 assert_eq!(c.id.as_str(), format!("continuity.{}", cli.path[1]));
1802 let writes = c.execution.effect == crate::capability::model::Effect::RepositoryMutation;
1803 assert_eq!(
1804 writes,
1805 matches!(cli.path[1].as_str(), "publish" | "sync" | "resume"),
1806 "{} writes the repository exactly when it publishes, syncs or resumes",
1807 c.id
1808 );
1809 let method = c.exposure.http.as_ref().map(|h| h.method);
1810 assert_eq!(
1811 method == Some(crate::capability::model::HttpMethod::Post),
1812 !c.kind.is_read_only(),
1813 "{}: a command is a POST and a read is a GET",
1814 c.id
1815 );
1816 }
1817
1818 let c = &m.capabilities[0].capability;
1819 let mcp = c.exposure.mcp.as_ref().expect("an MCP projection");
1820 assert_eq!(mcp.tool.as_deref(), Some("majordomus_continuity"));
1821 assert_eq!(
1822 mcp.resource.as_ref().map(|r| r.uri.as_str()),
1823 Some(CONTINUITY_URI)
1824 );
1825 assert_eq!(
1826 c.exposure.http.as_ref().map(|h| h.path.as_str()),
1827 Some("/api/v1/continuity")
1828 );
1829 assert!(c.kind.is_read_only() && c.kind.is_executable());
1831 assert!(ids.iter().all(|id| id.starts_with("continuity.")));
1833 }
1834}