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 {
117 !matches!(self, Self::Merged | Self::Ready | Self::Failed)
118 }
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct Candidate {
124 pub index: usize,
126 pub label: char,
128 pub agent: String,
131 pub branch: String,
134 pub worktree: PathBuf,
136 #[serde(default)]
138 pub summary: String,
139 #[serde(default)]
141 pub stat: String,
142 #[serde(default)]
144 pub files: usize,
145 #[serde(default)]
147 pub commits: usize,
148 #[serde(default)]
150 pub empty: bool,
151 #[serde(default)]
153 pub failed: Option<String>,
154 #[serde(default)]
156 pub duration_ms: u64,
157 #[serde(default)]
159 pub folded: bool,
160}
161
162impl Candidate {
163 pub fn viable(&self) -> bool {
165 self.failed.is_none() && !self.empty
166 }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct Judgement {
172 pub judge: usize,
174 pub seat: String,
176 pub agent: String,
178 #[serde(default)]
180 pub ranking: Vec<char>,
181 #[serde(default)]
183 pub reasons: BTreeMap<String, String>,
184 #[serde(default)]
186 pub confidence: Option<u8>,
187 #[serde(default)]
189 pub order: Vec<usize>,
190 #[serde(default)]
192 pub failed: Option<String>,
193 #[serde(default)]
195 pub duration_ms: u64,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct DeliberationTurn {
201 pub judge: usize,
203 pub agent: String,
205 pub body: String,
207 #[serde(default)]
209 pub tentative: Option<char>,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct DeliberationRound {
215 pub round: usize,
217 pub turns: Vec<DeliberationTurn>,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct VoteRecord {
224 pub judge: usize,
226 pub agent: String,
228 #[serde(default)]
230 pub vote: Option<char>,
231 #[serde(default)]
233 pub reason: String,
234 #[serde(default)]
236 pub changed: bool,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct QuotaLoss {
243 pub seat: String,
245 pub node: String,
247 pub at: Timestamp,
249 #[serde(default)]
251 pub reset: Option<String>,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct Tally {
257 pub first_choice: BTreeMap<char, usize>,
259 pub borda: BTreeMap<char, usize>,
261 pub winner: char,
263 #[serde(default)]
266 pub rankings: usize,
267 pub unanimous_initial: bool,
269 pub deliberated: bool,
271 pub changed_votes: usize,
273 pub unanimous_final: bool,
275 #[serde(default)]
277 pub tie_break: Option<String>,
278 #[serde(default)]
280 pub judges: usize,
281 #[serde(default)]
284 pub present: usize,
285 #[serde(default)]
290 pub quorum: usize,
291 #[serde(default)]
293 pub met_quorum: bool,
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct ReviewRecord {
299 pub reviewer: usize,
301 pub agent: String,
303 #[serde(default)]
305 pub summary: String,
306 #[serde(default)]
308 pub findings: Vec<Finding>,
309 #[serde(default)]
311 pub failed: Option<String>,
312 #[serde(default)]
314 pub duration_ms: u64,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct FixRecord {
320 pub agent: String,
322 #[serde(default)]
324 pub addressed: Vec<String>,
325 #[serde(default)]
327 pub rejected: Vec<Rejection>,
328 #[serde(default)]
330 pub notes: String,
331 #[serde(default)]
333 pub committed: bool,
334 #[serde(default)]
336 pub failed: Option<String>,
337 #[serde(default)]
339 pub duration_ms: u64,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct CommandOutcome {
345 pub command: String,
347 pub code: Option<i32>,
349 #[serde(default)]
351 pub output_tail: String,
352 #[serde(default)]
354 pub duration_ms: u64,
355}
356
357impl CommandOutcome {
358 pub fn ok(&self) -> bool {
360 self.code == Some(0)
361 }
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct ReviewRound {
367 pub round: usize,
369 pub head: String,
371 pub reviews: Vec<ReviewRecord>,
373 #[serde(default)]
375 pub e2e: Vec<CommandOutcome>,
376 #[serde(default)]
378 pub fix: Option<FixRecord>,
379 #[serde(default)]
381 pub blocking: usize,
382 #[serde(default)]
384 pub clean: bool,
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct MergeOutcome {
390 pub mode: MergeMode,
392 pub ok: bool,
394 #[serde(default)]
396 pub detail: String,
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct Event {
402 pub at: Timestamp,
404 pub node: String,
406 pub message: String,
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize)]
417pub struct PrRecord {
418 pub url: String,
420 pub number: u64,
422 pub state: String,
424 pub checks: String,
426 pub round: usize,
428 pub rounds: usize,
430}
431
432#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct RunState {
435 pub schema: u32,
437 pub id: String,
439 pub repo: PathBuf,
441 pub base_branch: String,
443 pub base_commit: String,
445 pub instruction: String,
447 pub created_at: Timestamp,
449 pub updated_at: Timestamp,
451 pub status: RunStatus,
453 pub seed: u64,
455 pub config: Config,
457 #[serde(default)]
459 pub enabled_worktree_config: bool,
460 #[serde(default)]
462 pub candidates: Vec<Candidate>,
463 #[serde(default)]
465 pub judgements: Vec<Judgement>,
466 #[serde(default)]
468 pub deliberation: Vec<DeliberationRound>,
469 #[serde(default)]
471 pub votes: Vec<VoteRecord>,
472 #[serde(default)]
474 pub tally: Option<Tally>,
475 #[serde(default)]
477 pub reviews: Vec<ReviewRound>,
478 #[serde(default)]
480 pub gate: Vec<CommandOutcome>,
481 #[serde(default)]
483 pub merge: Option<MergeOutcome>,
484 #[serde(default)]
486 pub leaks: Vec<Leak>,
487 #[serde(default)]
489 pub quota: Vec<QuotaLoss>,
490 #[serde(default)]
497 pub parked: bool,
498 #[serde(default)]
500 pub seats: BTreeMap<String, SeatState>,
501 #[serde(default)]
508 pub pr: Option<PrRecord>,
509 #[serde(default)]
511 pub events: Vec<Event>,
512}
513
514impl RunState {
515 pub fn new(
517 repo: PathBuf,
518 base_branch: String,
519 base_commit: String,
520 instruction: String,
521 config: Config,
522 ) -> Self {
523 let now = Timestamp::now();
524 let seed = config.blind.seed.unwrap_or_else(crate::rng::entropy);
525 Self {
526 schema: SCHEMA,
527 id: new_id(seed),
528 repo,
529 base_branch,
530 base_commit,
531 instruction,
532 created_at: now,
533 updated_at: now,
534 status: RunStatus::Prep,
535 seed,
536 config,
537 enabled_worktree_config: false,
538 candidates: Vec::new(),
539 judgements: Vec::new(),
540 deliberation: Vec::new(),
541 votes: Vec::new(),
542 tally: None,
543 reviews: Vec::new(),
544 gate: Vec::new(),
545 merge: None,
546 leaks: Vec::new(),
547 quota: Vec::new(),
548 parked: false,
549 seats: BTreeMap::new(),
550 pr: None,
551 events: Vec::new(),
552 }
553 }
554
555 pub fn dir(&self) -> PathBuf {
557 run_dir(&self.id)
558 }
559
560 pub fn short(&self) -> &str {
562 short_of(&self.id)
563 }
564
565 pub fn branch_for(&self, label: char) -> String {
567 format!("magi/{}/{}", self.short(), label)
568 }
569
570 pub fn worktree_root(&self) -> PathBuf {
572 self.config
573 .graph
574 .worktree_root
575 .clone()
576 .unwrap_or_else(|| {
577 dirs::home_dir()
578 .unwrap_or_else(|| PathBuf::from("."))
579 .join("wt")
580 .join("magi")
581 })
582 .join(self.short())
583 }
584
585 pub fn event(&mut self, node: &str, message: impl Into<String>) {
587 let message = message.into();
588 tracing::info!(node, "{message}");
589 self.events.push(Event {
590 at: Timestamp::now(),
591 node: node.to_owned(),
592 message,
593 });
594 }
595
596 pub fn save(&mut self) -> Result<()> {
598 self.updated_at = Timestamp::now();
599 let dir = self.dir();
600 std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
601 let body = serde_json::to_string_pretty(self).context("serialize run state")?;
602 let tmp = dir.join("run.json.tmp");
603 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
604 std::fs::rename(&tmp, dir.join("run.json")).with_context(|| "replace run.json")?;
605 Ok(())
606 }
607
608 pub fn load(id: &str) -> Result<Self> {
610 let resolved = resolve_id(id)?;
611 let path = run_dir(&resolved).join("run.json");
612 let body =
613 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
614 let state: Self =
615 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
616 if state.schema != SCHEMA {
617 bail!(
618 "run {} was written by a different magi (schema {}, this build \
619 speaks {SCHEMA})",
620 state.id,
621 state.schema
622 );
623 }
624 Ok(state)
625 }
626
627 pub fn winner(&self) -> Option<&Candidate> {
629 let label = self.tally.as_ref()?.winner;
630 self.candidates.iter().find(|c| c.label == label)
631 }
632
633 pub fn viable(&self) -> Vec<&Candidate> {
635 self.candidates.iter().filter(|c| c.viable()).collect()
636 }
637
638 pub fn created_local(&self) -> String {
640 self.created_at
641 .to_zoned(jiff::tz::TimeZone::system())
642 .strftime("%Y-%m-%d %H:%M:%S")
643 .to_string()
644 }
645
646 pub fn ensure_can_delete(&self, in_flight: bool) -> Result<()> {
663 if in_flight {
664 bail!(
665 "run {} is being worked on by a live daemon right now",
666 self.short()
667 );
668 }
669 if self.candidates.iter().any(|c| !c.folded) {
670 bail!(
671 "run {} has unfolded candidates; fold first with `magi fold`",
672 self.short()
673 );
674 }
675 Ok(())
676 }
677}
678
679pub fn short_of(id: &str) -> &str {
686 id.split('-').next_back().unwrap_or(id)
687}
688
689pub fn home() -> PathBuf {
695 if let Some(dir) = HOME.get() {
696 return dir.clone();
697 }
698 if let Some(dir) = std::env::var_os("MAGI_HOME") {
699 return PathBuf::from(dir);
700 }
701 dirs::data_local_dir()
702 .unwrap_or_else(|| PathBuf::from("."))
703 .join("magi")
704}
705
706pub fn set_home(dir: PathBuf) {
708 let _ = HOME.set(dir);
709}
710
711static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
712
713pub fn runs_root() -> PathBuf {
715 home().join("runs")
716}
717
718pub fn run_dir(id: &str) -> PathBuf {
720 runs_root().join(id)
721}
722
723pub fn list_ids() -> Vec<String> {
725 let mut ids: Vec<String> = std::fs::read_dir(runs_root())
726 .into_iter()
727 .flatten()
728 .flatten()
729 .filter(|e| e.path().join("run.json").is_file())
730 .map(|e| e.file_name().to_string_lossy().into_owned())
731 .collect();
732 ids.sort_unstable_by(|a, b| b.cmp(a));
734 ids
735}
736
737pub fn resolve_id(prefix: &str) -> Result<String> {
739 if run_dir(prefix).join("run.json").is_file() {
740 return Ok(prefix.to_owned());
741 }
742 let hits: Vec<String> = list_ids()
743 .into_iter()
744 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
745 .collect();
746 match hits.len() {
747 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
748 0 => bail!("no run matches `{prefix}`"),
749 _ => bail!(
750 "`{prefix}` matches {} runs: {}",
751 hits.len(),
752 hits.join(", ")
753 ),
754 }
755}
756
757pub fn latest_id() -> Option<String> {
759 list_ids().into_iter().next()
760}
761
762fn new_id(seed: u64) -> String {
764 let stamp = Zoned::now().strftime("%Y%m%d-%H%M%S");
765 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
766}
767
768pub fn tail(text: &str, max: usize) -> String {
770 if text.len() <= max {
771 return text.to_owned();
772 }
773 let mut cut = text.len() - max;
774 while cut < text.len() && !text.is_char_boundary(cut) {
775 cut += 1;
776 }
777 let slice = &text[cut..];
778 let start = slice.find('\n').map_or(0, |i| i + 1);
779 format!(
780 "[... {} earlier bytes omitted ...]\n{}",
781 cut,
782 &slice[start..]
783 )
784}
785
786pub fn artifact_path(run: &RunState, name: &str) -> PathBuf {
788 run.dir().join("artifacts").join(name)
789}
790
791pub fn write_artifact(run: &RunState, name: &str, body: &str) -> Result<PathBuf> {
793 let path = artifact_path(run, name);
794 if let Some(parent) = path.parent() {
795 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
796 }
797 std::fs::write(&path, body).with_context(|| format!("write {}", path.display()))?;
798 Ok(path)
799}
800
801pub fn read_artifact(run: &RunState, name: &str) -> Option<String> {
803 std::fs::read_to_string(artifact_path(run, name)).ok()
804}
805
806#[cfg(test)]
807mod tests {
808 use super::*;
809
810 fn state() -> RunState {
811 RunState::new(
812 PathBuf::from("/repo"),
813 "main".to_owned(),
814 "abc1234def".to_owned(),
815 "add retries".to_owned(),
816 Config::default(),
817 )
818 }
819
820 #[test]
821 fn ids_are_sortable_and_short_suffixed() {
822 let s = state();
823 let parts: Vec<&str> = s.id.split('-').collect();
824 assert_eq!(parts.len(), 3);
825 assert_eq!(parts[0].len(), 8);
826 assert_eq!(parts[1].len(), 6);
827 assert_eq!(parts[2].len(), 4);
828 assert_eq!(s.short(), parts[2]);
829 }
830
831 #[test]
832 fn branch_names_carry_the_label_not_the_author() {
833 let s = state();
834 let b = s.branch_for('B');
835 assert_eq!(b, format!("magi/{}/B", s.short()));
836 assert!(!b.contains("claude"));
837 }
838
839 #[test]
840 fn seed_from_config_makes_the_run_reproducible() {
841 let mut cfg = Config::default();
842 cfg.blind.seed = Some(1234);
843 let a = RunState::new(
844 PathBuf::from("/r"),
845 "main".to_owned(),
846 "c".to_owned(),
847 "t".to_owned(),
848 cfg.clone(),
849 );
850 let b = RunState::new(
851 PathBuf::from("/r"),
852 "main".to_owned(),
853 "c".to_owned(),
854 "t".to_owned(),
855 cfg,
856 );
857 assert_eq!(a.seed, 1234);
858 assert_eq!(a.seed, b.seed);
859 assert_eq!(a.short(), b.short());
860 }
861
862 #[test]
863 fn status_terminality() {
864 assert!(RunStatus::Merged.done());
865 assert!(RunStatus::Blocked.done());
866 assert!(!RunStatus::Reviewing.done());
867 }
868
869 #[test]
870 fn candidate_viability_excludes_empty_and_failed() {
871 let mut c = Candidate {
872 index: 0,
873 label: 'A',
874 agent: "a".to_owned(),
875 branch: "b".to_owned(),
876 worktree: PathBuf::from("/w"),
877 summary: String::new(),
878 stat: String::new(),
879 files: 1,
880 commits: 1,
881 empty: false,
882 failed: None,
883 duration_ms: 0,
884 folded: false,
885 };
886 assert!(c.viable());
887 c.empty = true;
888 assert!(!c.viable());
889 c.empty = false;
890 c.failed = Some("timeout".to_owned());
891 assert!(!c.viable());
892 }
893
894 #[test]
895 fn tail_keeps_the_end_on_a_line_boundary() {
896 let text = (0..100).map(|i| format!("line {i}\n")).collect::<String>();
897 let t = tail(&text, 40);
898 assert!(t.starts_with("[..."));
899 assert!(t.ends_with("line 99\n"));
900 assert!(t.len() < 120);
901 assert_eq!(tail("short", 40), "short");
902 }
903
904 #[test]
905 fn tail_survives_multibyte_cuts() {
906 let text = "あ".repeat(50);
907 let t = tail(&text, 10);
908 assert!(t.contains("earlier bytes omitted"));
909 assert!(t.ends_with('あ'));
910 }
911
912 #[test]
913 fn state_round_trips_through_json() {
914 let s = state();
915 let body = serde_json::to_string(&s).unwrap();
916 let back: RunState = serde_json::from_str(&body).unwrap();
917 assert_eq!(back.id, s.id);
918 assert_eq!(back.instruction, "add retries");
919 assert_eq!(back.status, RunStatus::Prep);
920 }
921
922 #[test]
923 fn ensure_can_delete_guards_live_and_unfolded_runs() {
924 let mut s = state();
925 s.status = RunStatus::Prep;
927 let err = s.ensure_can_delete(true).unwrap_err().to_string();
928 assert!(err.contains("live daemon"), "{err}");
929
930 assert!(s.ensure_can_delete(false).is_ok());
934
935 s.status = RunStatus::Merged;
938 s.candidates.push(Candidate {
939 index: 0,
940 label: 'A',
941 agent: "a".to_owned(),
942 branch: "b".to_owned(),
943 worktree: PathBuf::from("/w"),
944 summary: String::new(),
945 stat: String::new(),
946 files: 1,
947 commits: 1,
948 empty: false,
949 failed: None,
950 duration_ms: 0,
951 folded: false,
952 });
953 let err = s.ensure_can_delete(false).unwrap_err().to_string();
954 assert!(
955 err.contains("magi fold"),
956 "error must suggest `magi fold`: {err}"
957 );
958
959 s.candidates[0].folded = true;
961 assert!(s.ensure_can_delete(false).is_ok());
962 }
963}