1use std::collections::BTreeMap;
12use std::path::PathBuf;
13
14use anyhow::{Context as _, Result, bail};
15use jiff::{Timestamp, Zoned};
16use serde::{Deserialize, Serialize};
17
18use crate::agent::SeatState;
19use crate::blind::Leak;
20use crate::config::{Config, MergeMode};
21use crate::verdict::{Finding, Rejection};
22
23pub const SCHEMA: u32 = 2;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum RunStatus {
36 Prep,
38 Implementing,
40 Judging,
42 Deliberating,
44 Voting,
46 Reviewing,
48 Gating,
50 Merged,
52 Ready,
54 Stalled,
59 Blocked,
61 Failed,
63}
64
65impl RunStatus {
66 pub fn done(self) -> bool {
68 matches!(
69 self,
70 Self::Merged | Self::Ready | Self::Stalled | Self::Blocked | Self::Failed
71 )
72 }
73
74 pub fn as_str(self) -> &'static str {
78 match self {
79 Self::Prep => "prep",
80 Self::Implementing => "implementing",
81 Self::Judging => "judging",
82 Self::Deliberating => "deliberating",
83 Self::Voting => "voting",
84 Self::Reviewing => "reviewing",
85 Self::Gating => "gating",
86 Self::Merged => "merged",
87 Self::Ready => "ready",
88 Self::Stalled => "stalled",
89 Self::Blocked => "blocked",
90 Self::Failed => "failed",
91 }
92 }
93
94 pub fn resumable(self) -> bool {
107 matches!(self, Self::Stalled | Self::Blocked)
108 }
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct Candidate {
114 pub index: usize,
116 pub label: char,
118 pub agent: String,
121 pub branch: String,
124 pub worktree: PathBuf,
126 #[serde(default)]
128 pub summary: String,
129 #[serde(default)]
131 pub stat: String,
132 #[serde(default)]
134 pub files: usize,
135 #[serde(default)]
137 pub commits: usize,
138 #[serde(default)]
140 pub empty: bool,
141 #[serde(default)]
143 pub failed: Option<String>,
144 #[serde(default)]
146 pub duration_ms: u64,
147 #[serde(default)]
149 pub folded: bool,
150}
151
152impl Candidate {
153 pub fn viable(&self) -> bool {
155 self.failed.is_none() && !self.empty
156 }
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct Judgement {
162 pub judge: usize,
164 pub seat: String,
166 pub agent: String,
168 #[serde(default)]
170 pub ranking: Vec<char>,
171 #[serde(default)]
173 pub reasons: BTreeMap<String, String>,
174 #[serde(default)]
176 pub confidence: Option<u8>,
177 #[serde(default)]
179 pub order: Vec<usize>,
180 #[serde(default)]
182 pub failed: Option<String>,
183 #[serde(default)]
185 pub duration_ms: u64,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct DeliberationTurn {
191 pub judge: usize,
193 pub agent: String,
195 pub body: String,
197 #[serde(default)]
199 pub tentative: Option<char>,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct DeliberationRound {
205 pub round: usize,
207 pub turns: Vec<DeliberationTurn>,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct VoteRecord {
214 pub judge: usize,
216 pub agent: String,
218 #[serde(default)]
220 pub vote: Option<char>,
221 #[serde(default)]
223 pub reason: String,
224 #[serde(default)]
226 pub changed: bool,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct QuotaLoss {
233 pub seat: String,
235 pub node: String,
237 pub at: Timestamp,
239 #[serde(default)]
241 pub reset: Option<String>,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct Tally {
247 pub first_choice: BTreeMap<char, usize>,
249 pub borda: BTreeMap<char, usize>,
251 pub winner: char,
253 #[serde(default)]
256 pub rankings: usize,
257 pub unanimous_initial: bool,
259 pub deliberated: bool,
261 pub changed_votes: usize,
263 pub unanimous_final: bool,
265 #[serde(default)]
267 pub tie_break: Option<String>,
268 #[serde(default)]
270 pub judges: usize,
271 #[serde(default)]
274 pub present: usize,
275 #[serde(default)]
280 pub quorum: usize,
281 #[serde(default)]
283 pub met_quorum: bool,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct ReviewRecord {
289 pub reviewer: usize,
291 pub agent: String,
293 #[serde(default)]
295 pub summary: String,
296 #[serde(default)]
298 pub findings: Vec<Finding>,
299 #[serde(default)]
301 pub failed: Option<String>,
302 #[serde(default)]
304 pub duration_ms: u64,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct FixRecord {
310 pub agent: String,
312 #[serde(default)]
314 pub addressed: Vec<String>,
315 #[serde(default)]
317 pub rejected: Vec<Rejection>,
318 #[serde(default)]
320 pub notes: String,
321 #[serde(default)]
323 pub committed: bool,
324 #[serde(default)]
326 pub failed: Option<String>,
327 #[serde(default)]
329 pub duration_ms: u64,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct CommandOutcome {
335 pub command: String,
337 pub code: Option<i32>,
339 #[serde(default)]
341 pub output_tail: String,
342 #[serde(default)]
344 pub duration_ms: u64,
345}
346
347impl CommandOutcome {
348 pub fn ok(&self) -> bool {
350 self.code == Some(0)
351 }
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct ReviewRound {
357 pub round: usize,
359 pub head: String,
361 pub reviews: Vec<ReviewRecord>,
363 #[serde(default)]
365 pub e2e: Vec<CommandOutcome>,
366 #[serde(default)]
368 pub fix: Option<FixRecord>,
369 #[serde(default)]
371 pub blocking: usize,
372 #[serde(default)]
374 pub clean: bool,
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize)]
379pub struct MergeOutcome {
380 pub mode: MergeMode,
382 pub ok: bool,
384 #[serde(default)]
386 pub detail: String,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct Event {
392 pub at: Timestamp,
394 pub node: String,
396 pub message: String,
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct PrRecord {
408 pub url: String,
410 pub number: u64,
412 pub state: String,
414 pub checks: String,
416 pub round: usize,
418 pub rounds: usize,
420}
421
422#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct RunState {
425 pub schema: u32,
427 pub id: String,
429 pub repo: PathBuf,
431 pub base_branch: String,
433 pub base_commit: String,
435 pub instruction: String,
437 pub created_at: Timestamp,
439 pub updated_at: Timestamp,
441 pub status: RunStatus,
443 pub seed: u64,
445 pub config: Config,
447 #[serde(default)]
449 pub enabled_worktree_config: bool,
450 #[serde(default)]
452 pub candidates: Vec<Candidate>,
453 #[serde(default)]
455 pub judgements: Vec<Judgement>,
456 #[serde(default)]
458 pub deliberation: Vec<DeliberationRound>,
459 #[serde(default)]
461 pub votes: Vec<VoteRecord>,
462 #[serde(default)]
464 pub tally: Option<Tally>,
465 #[serde(default)]
467 pub reviews: Vec<ReviewRound>,
468 #[serde(default)]
470 pub gate: Vec<CommandOutcome>,
471 #[serde(default)]
473 pub merge: Option<MergeOutcome>,
474 #[serde(default)]
476 pub leaks: Vec<Leak>,
477 #[serde(default)]
479 pub quota: Vec<QuotaLoss>,
480 #[serde(default)]
487 pub parked: bool,
488 #[serde(default)]
490 pub seats: BTreeMap<String, SeatState>,
491 #[serde(default)]
498 pub pr: Option<PrRecord>,
499 #[serde(default)]
501 pub events: Vec<Event>,
502}
503
504impl RunState {
505 pub fn new(
507 repo: PathBuf,
508 base_branch: String,
509 base_commit: String,
510 instruction: String,
511 config: Config,
512 ) -> Self {
513 let now = Timestamp::now();
514 let seed = config.blind.seed.unwrap_or_else(crate::rng::entropy);
515 Self {
516 schema: SCHEMA,
517 id: new_id(seed),
518 repo,
519 base_branch,
520 base_commit,
521 instruction,
522 created_at: now,
523 updated_at: now,
524 status: RunStatus::Prep,
525 seed,
526 config,
527 enabled_worktree_config: false,
528 candidates: Vec::new(),
529 judgements: Vec::new(),
530 deliberation: Vec::new(),
531 votes: Vec::new(),
532 tally: None,
533 reviews: Vec::new(),
534 gate: Vec::new(),
535 merge: None,
536 leaks: Vec::new(),
537 quota: Vec::new(),
538 parked: false,
539 seats: BTreeMap::new(),
540 pr: None,
541 events: Vec::new(),
542 }
543 }
544
545 pub fn dir(&self) -> PathBuf {
547 run_dir(&self.id)
548 }
549
550 pub fn short(&self) -> &str {
552 short_of(&self.id)
553 }
554
555 pub fn branch_for(&self, label: char) -> String {
557 format!("magi/{}/{}", self.short(), label)
558 }
559
560 pub fn worktree_root(&self) -> PathBuf {
562 self.config
563 .graph
564 .worktree_root
565 .clone()
566 .unwrap_or_else(|| {
567 dirs::home_dir()
568 .unwrap_or_else(|| PathBuf::from("."))
569 .join("wt")
570 .join("magi")
571 })
572 .join(self.short())
573 }
574
575 pub fn event(&mut self, node: &str, message: impl Into<String>) {
577 let message = message.into();
578 tracing::info!(node, "{message}");
579 self.events.push(Event {
580 at: Timestamp::now(),
581 node: node.to_owned(),
582 message,
583 });
584 }
585
586 pub fn save(&mut self) -> Result<()> {
588 self.updated_at = Timestamp::now();
589 let dir = self.dir();
590 std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
591 let body = serde_json::to_string_pretty(self).context("serialize run state")?;
592 let tmp = dir.join("run.json.tmp");
593 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
594 std::fs::rename(&tmp, dir.join("run.json")).with_context(|| "replace run.json")?;
595 Ok(())
596 }
597
598 pub fn load(id: &str) -> Result<Self> {
600 let resolved = resolve_id(id)?;
601 let path = run_dir(&resolved).join("run.json");
602 let body =
603 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
604 let state: Self =
605 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
606 if state.schema != SCHEMA {
607 bail!(
608 "run {} was written by a different magi (schema {}, this build \
609 speaks {SCHEMA})",
610 state.id,
611 state.schema
612 );
613 }
614 Ok(state)
615 }
616
617 pub fn winner(&self) -> Option<&Candidate> {
619 let label = self.tally.as_ref()?.winner;
620 self.candidates.iter().find(|c| c.label == label)
621 }
622
623 pub fn viable(&self) -> Vec<&Candidate> {
625 self.candidates.iter().filter(|c| c.viable()).collect()
626 }
627
628 pub fn created_local(&self) -> String {
630 self.created_at
631 .to_zoned(jiff::tz::TimeZone::system())
632 .strftime("%Y-%m-%d %H:%M:%S")
633 .to_string()
634 }
635
636 pub fn ensure_can_delete(&self, in_flight: bool) -> Result<()> {
653 if in_flight {
654 bail!(
655 "run {} is being worked on by a live daemon right now",
656 self.short()
657 );
658 }
659 if self.candidates.iter().any(|c| !c.folded) {
660 bail!(
661 "run {} has unfolded candidates; fold first with `magi fold`",
662 self.short()
663 );
664 }
665 Ok(())
666 }
667}
668
669pub fn short_of(id: &str) -> &str {
676 id.split('-').next_back().unwrap_or(id)
677}
678
679pub fn home() -> PathBuf {
685 if let Some(dir) = HOME.get() {
686 return dir.clone();
687 }
688 if let Some(dir) = std::env::var_os("MAGI_HOME") {
689 return PathBuf::from(dir);
690 }
691 dirs::data_local_dir()
692 .unwrap_or_else(|| PathBuf::from("."))
693 .join("magi")
694}
695
696pub fn set_home(dir: PathBuf) {
698 let _ = HOME.set(dir);
699}
700
701static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
702
703pub fn runs_root() -> PathBuf {
705 home().join("runs")
706}
707
708pub fn run_dir(id: &str) -> PathBuf {
710 runs_root().join(id)
711}
712
713pub fn list_ids() -> Vec<String> {
715 let mut ids: Vec<String> = std::fs::read_dir(runs_root())
716 .into_iter()
717 .flatten()
718 .flatten()
719 .filter(|e| e.path().join("run.json").is_file())
720 .map(|e| e.file_name().to_string_lossy().into_owned())
721 .collect();
722 ids.sort_unstable_by(|a, b| b.cmp(a));
724 ids
725}
726
727pub fn resolve_id(prefix: &str) -> Result<String> {
729 if run_dir(prefix).join("run.json").is_file() {
730 return Ok(prefix.to_owned());
731 }
732 let hits: Vec<String> = list_ids()
733 .into_iter()
734 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
735 .collect();
736 match hits.len() {
737 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
738 0 => bail!("no run matches `{prefix}`"),
739 _ => bail!(
740 "`{prefix}` matches {} runs: {}",
741 hits.len(),
742 hits.join(", ")
743 ),
744 }
745}
746
747pub fn latest_id() -> Option<String> {
749 list_ids().into_iter().next()
750}
751
752fn new_id(seed: u64) -> String {
754 let stamp = Zoned::now().strftime("%Y%m%d-%H%M%S");
755 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
756}
757
758pub fn tail(text: &str, max: usize) -> String {
760 if text.len() <= max {
761 return text.to_owned();
762 }
763 let mut cut = text.len() - max;
764 while cut < text.len() && !text.is_char_boundary(cut) {
765 cut += 1;
766 }
767 let slice = &text[cut..];
768 let start = slice.find('\n').map_or(0, |i| i + 1);
769 format!(
770 "[... {} earlier bytes omitted ...]\n{}",
771 cut,
772 &slice[start..]
773 )
774}
775
776pub fn artifact_path(run: &RunState, name: &str) -> PathBuf {
778 run.dir().join("artifacts").join(name)
779}
780
781pub fn write_artifact(run: &RunState, name: &str, body: &str) -> Result<PathBuf> {
783 let path = artifact_path(run, name);
784 if let Some(parent) = path.parent() {
785 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
786 }
787 std::fs::write(&path, body).with_context(|| format!("write {}", path.display()))?;
788 Ok(path)
789}
790
791pub fn read_artifact(run: &RunState, name: &str) -> Option<String> {
793 std::fs::read_to_string(artifact_path(run, name)).ok()
794}
795
796#[cfg(test)]
797mod tests {
798 use super::*;
799
800 fn state() -> RunState {
801 RunState::new(
802 PathBuf::from("/repo"),
803 "main".to_owned(),
804 "abc1234def".to_owned(),
805 "add retries".to_owned(),
806 Config::default(),
807 )
808 }
809
810 #[test]
811 fn ids_are_sortable_and_short_suffixed() {
812 let s = state();
813 let parts: Vec<&str> = s.id.split('-').collect();
814 assert_eq!(parts.len(), 3);
815 assert_eq!(parts[0].len(), 8);
816 assert_eq!(parts[1].len(), 6);
817 assert_eq!(parts[2].len(), 4);
818 assert_eq!(s.short(), parts[2]);
819 }
820
821 #[test]
822 fn branch_names_carry_the_label_not_the_author() {
823 let s = state();
824 let b = s.branch_for('B');
825 assert_eq!(b, format!("magi/{}/B", s.short()));
826 assert!(!b.contains("claude"));
827 }
828
829 #[test]
830 fn seed_from_config_makes_the_run_reproducible() {
831 let mut cfg = Config::default();
832 cfg.blind.seed = Some(1234);
833 let a = RunState::new(
834 PathBuf::from("/r"),
835 "main".to_owned(),
836 "c".to_owned(),
837 "t".to_owned(),
838 cfg.clone(),
839 );
840 let b = RunState::new(
841 PathBuf::from("/r"),
842 "main".to_owned(),
843 "c".to_owned(),
844 "t".to_owned(),
845 cfg,
846 );
847 assert_eq!(a.seed, 1234);
848 assert_eq!(a.seed, b.seed);
849 assert_eq!(a.short(), b.short());
850 }
851
852 #[test]
853 fn status_terminality() {
854 assert!(RunStatus::Merged.done());
855 assert!(RunStatus::Blocked.done());
856 assert!(!RunStatus::Reviewing.done());
857 }
858
859 #[test]
860 fn candidate_viability_excludes_empty_and_failed() {
861 let mut c = Candidate {
862 index: 0,
863 label: 'A',
864 agent: "a".to_owned(),
865 branch: "b".to_owned(),
866 worktree: PathBuf::from("/w"),
867 summary: String::new(),
868 stat: String::new(),
869 files: 1,
870 commits: 1,
871 empty: false,
872 failed: None,
873 duration_ms: 0,
874 folded: false,
875 };
876 assert!(c.viable());
877 c.empty = true;
878 assert!(!c.viable());
879 c.empty = false;
880 c.failed = Some("timeout".to_owned());
881 assert!(!c.viable());
882 }
883
884 #[test]
885 fn tail_keeps_the_end_on_a_line_boundary() {
886 let text = (0..100).map(|i| format!("line {i}\n")).collect::<String>();
887 let t = tail(&text, 40);
888 assert!(t.starts_with("[..."));
889 assert!(t.ends_with("line 99\n"));
890 assert!(t.len() < 120);
891 assert_eq!(tail("short", 40), "short");
892 }
893
894 #[test]
895 fn tail_survives_multibyte_cuts() {
896 let text = "あ".repeat(50);
897 let t = tail(&text, 10);
898 assert!(t.contains("earlier bytes omitted"));
899 assert!(t.ends_with('あ'));
900 }
901
902 #[test]
903 fn state_round_trips_through_json() {
904 let s = state();
905 let body = serde_json::to_string(&s).unwrap();
906 let back: RunState = serde_json::from_str(&body).unwrap();
907 assert_eq!(back.id, s.id);
908 assert_eq!(back.instruction, "add retries");
909 assert_eq!(back.status, RunStatus::Prep);
910 }
911
912 #[test]
913 fn ensure_can_delete_guards_live_and_unfolded_runs() {
914 let mut s = state();
915 s.status = RunStatus::Prep;
917 let err = s.ensure_can_delete(true).unwrap_err().to_string();
918 assert!(err.contains("live daemon"), "{err}");
919
920 assert!(s.ensure_can_delete(false).is_ok());
924
925 s.status = RunStatus::Merged;
928 s.candidates.push(Candidate {
929 index: 0,
930 label: 'A',
931 agent: "a".to_owned(),
932 branch: "b".to_owned(),
933 worktree: PathBuf::from("/w"),
934 summary: String::new(),
935 stat: String::new(),
936 files: 1,
937 commits: 1,
938 empty: false,
939 failed: None,
940 duration_ms: 0,
941 folded: false,
942 });
943 let err = s.ensure_can_delete(false).unwrap_err().to_string();
944 assert!(
945 err.contains("magi fold"),
946 "error must suggest `magi fold`: {err}"
947 );
948
949 s.candidates[0].folded = true;
951 assert!(s.ensure_can_delete(false).is_ok());
952 }
953}