1use std::collections::BTreeMap;
16
17use serde::Serialize;
18
19use crate::config::{MergeMode, MergeStyle};
20use crate::run::{
21 CommandOutcome, ContinuationOutcome, E2eStatus, GateStatus, JobStatus, Liveness,
22 OperatorFixOutcome, ReviewRound, RunState, tail,
23};
24use crate::verdict::{Finding, ReviewVote, Severity};
25
26pub const SCHEMA: u32 = 1;
29
30const OUTPUT_TAIL: usize = 2_000;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
36#[serde(rename_all = "snake_case")]
37pub enum Tone {
38 Ok,
40 Warn,
42 Fail,
44 Neutral,
46}
47
48#[derive(Debug, Serialize)]
50pub struct RunReportView {
51 pub schema: u32,
53 pub header: Header,
55 pub sections: Vec<Section>,
57 pub active_seats: String,
60}
61
62#[derive(Debug, Serialize)]
64pub struct Header {
65 pub id: String,
67 pub status: String,
69 pub tone: Tone,
71 pub unmerged_by_design: bool,
73 pub needs_attention: bool,
75 pub repo: String,
77 pub base_branch: String,
79 pub base_commit: String,
81 pub created: String,
83 pub task: String,
85 pub origin: String,
87 pub state_dir: String,
89}
90
91#[derive(Debug, Serialize)]
93pub struct Head {
94 pub title: String,
96 pub tone: Tone,
98 pub default_open: bool,
100}
101
102fn head(title: &str, tone: Tone, default_open: bool) -> Head {
103 Head {
104 title: title.to_owned(),
105 tone,
106 default_open,
107 }
108}
109
110#[derive(Debug, Serialize)]
112#[serde(tag = "kind", rename_all = "snake_case")]
113pub enum Section {
114 Candidates {
116 #[serde(flatten)]
118 head: Head,
119 items: Vec<CandidateRow>,
121 },
122 Judging {
124 #[serde(flatten)]
126 head: Head,
127 judges: Vec<JudgeRow>,
129 deliberation: Vec<String>,
131 rounds: Vec<DeliberationRoundView>,
133 votes: Vec<VoteRow>,
135 },
136 Tally {
138 #[serde(flatten)]
140 head: Head,
141 tally: TallyView,
143 },
144 Notes {
146 #[serde(flatten)]
148 head: Head,
149 key: &'static str,
151 lines: Vec<String>,
153 },
154 Graft {
156 #[serde(flatten)]
158 head: Head,
159 graft: GraftView,
161 },
162 Review {
164 #[serde(flatten)]
166 head: Head,
167 rounds: Vec<RoundView>,
169 handed_off: Option<String>,
171 },
172 OperatorFixes {
174 #[serde(flatten)]
176 head: Head,
177 requests: Vec<OperatorFixView>,
179 },
180 BaseSync {
182 #[serde(flatten)]
184 head: Head,
185 base_sync: BaseSyncView,
187 },
188 PreGate {
190 #[serde(flatten)]
192 head: Head,
193 commands: Vec<CommandView>,
195 committed: Option<String>,
197 },
198 Gate {
200 #[serde(flatten)]
202 head: Head,
203 status: &'static str,
205 commands: Vec<CommandView>,
207 },
208 Merge {
210 #[serde(flatten)]
212 head: Head,
213 merge: MergeView,
215 },
216 Followups {
218 #[serde(flatten)]
220 head: Head,
221 items: Vec<FollowupRow>,
223 },
224 ReleaseBump {
226 #[serde(flatten)]
228 head: Head,
229 bump: ReleaseBumpView,
231 },
232 WinnerWorktree {
234 #[serde(flatten)]
236 head: Head,
237 worktree: String,
239 branch: String,
241 provisional: bool,
243 },
244 Jobs {
246 #[serde(flatten)]
248 head: Head,
249 groups: Vec<JobGroup>,
251 coverage: &'static str,
253 empty_note: Option<&'static str>,
255 },
256}
257
258#[derive(Debug, Serialize)]
260pub struct CandidateRow {
261 pub label: String,
263 pub agent: String,
265 pub state: &'static str,
267 pub detail: Option<String>,
269 pub files: usize,
271 pub commits: usize,
273 pub duration_secs: u64,
275 pub winner: bool,
277 pub provisional_winner: bool,
279}
280
281#[derive(Debug, Serialize)]
283pub struct JudgeRow {
284 pub judge: usize,
286 pub agent: String,
288 pub ranking: String,
290 pub confidence: Option<u8>,
292 pub failed: Option<String>,
294}
295
296#[derive(Debug, Serialize)]
298pub struct VoteRow {
299 pub judge: usize,
301 pub agent: String,
303 pub vote: String,
305 pub changed: bool,
307}
308
309#[derive(Debug, Serialize)]
311pub struct DeliberationTurnView {
312 pub judge: usize,
314 pub agent: String,
316 pub tentative: Option<String>,
318 pub body: String,
320 pub body_md: Vec<crate::md::Node>,
322}
323
324#[derive(Debug, Serialize)]
326pub struct DeliberationRoundView {
327 pub round: usize,
329 pub turns: Vec<DeliberationTurnView>,
331}
332
333#[derive(Debug, Serialize)]
335pub struct TallyView {
336 pub met_quorum: bool,
338 pub decided: bool,
340 pub provisional: bool,
342 pub winner: String,
344 pub judges: usize,
346 pub present: usize,
348 pub quorum: usize,
350 pub uncontested: Option<String>,
352 pub first_choice: BTreeMap<String, usize>,
354 pub initial: &'static str,
356 pub unanimous_final: bool,
358 pub changed_votes: usize,
360 pub tie_break: Option<String>,
362 pub rate_limited: Vec<String>,
364}
365
366#[derive(Debug, Serialize)]
368pub struct RoundView {
369 pub round: usize,
371 pub status: &'static str,
373 pub tone: Tone,
375 pub head: String,
377 pub verified_head: Option<String>,
379 pub raised: usize,
381 pub blocking: usize,
383 pub answered: usize,
385 pub expected: usize,
387 pub missing: Vec<String>,
389 pub e2e: E2eView,
391 pub verdict: Option<VerdictView>,
393 pub fix: Option<FixView>,
395 pub commands: Vec<CommandView>,
397 pub reviewers: Vec<ReviewerRow>,
399 pub findings: Vec<FindingView>,
401 pub reconsideration: Vec<RevoteRow>,
403 pub default_open: bool,
405}
406
407#[derive(Debug, Serialize)]
409pub struct E2eView {
410 pub state: &'static str,
412 pub build_failed: bool,
414 pub defer_reason: Option<String>,
416 pub retried: bool,
418}
419
420#[derive(Debug, Serialize)]
422pub struct VerdictView {
423 pub vote: &'static str,
425 pub label: &'static str,
427 pub split: bool,
429}
430
431#[derive(Debug, Serialize)]
433pub struct FixView {
434 pub agent: String,
436 pub addressed: usize,
438 pub rejected: usize,
440 pub committed: bool,
442 pub tree_changed: bool,
444 pub report_lost: Option<String>,
446 pub continuation: Option<String>,
448}
449
450#[derive(Debug, Serialize)]
452pub struct ReviewerRow {
453 pub reviewer: usize,
455 pub agent: String,
457 pub vote: Option<&'static str>,
459 pub failed: Option<String>,
461}
462
463#[derive(Debug, Serialize)]
465pub struct RevoteRow {
466 pub reviewer: usize,
468 pub vote: Option<&'static str>,
470 pub reason: String,
472}
473
474#[derive(Debug, Serialize)]
476pub struct FindingView {
477 pub id: String,
479 pub reviewer: Option<usize>,
481 pub severity: Option<&'static str>,
483 pub title: String,
485 pub file: Option<String>,
487 pub line: Option<u32>,
489 pub blocking: bool,
491 pub state: &'static str,
493 pub declined_why: Option<String>,
495}
496
497#[derive(Debug, Serialize)]
499pub struct CommandView {
500 pub command: String,
502 pub state: &'static str,
504 pub code: Option<i32>,
506 pub duration_ms: u64,
508 pub output_tail: Option<String>,
510}
511
512#[derive(Debug, Serialize)]
514pub struct OperatorFixView {
515 pub requested_at: String,
517 pub stale: bool,
519 pub reason: String,
521 pub findings: Vec<OperatorFindingRow>,
523 pub follow_up_review_run: Option<String>,
525 pub unverified_commit: bool,
527}
528
529#[derive(Debug, Serialize)]
531pub struct OperatorFindingRow {
532 pub id: String,
534 pub severity: &'static str,
536 pub title: String,
538 pub outcome: &'static str,
540 pub why: Option<String>,
542}
543
544#[derive(Debug, Serialize)]
546pub struct BaseSyncView {
547 pub base_branch: String,
549 pub tip: String,
551 pub state: &'static str,
553 pub behind: usize,
555 pub attempts: usize,
557 pub episode_used: usize,
559 pub episode_limit: usize,
561 pub next_action: String,
563 pub conflict: Option<String>,
565 pub already_in: Option<String>,
567}
568
569#[derive(Debug, Serialize)]
571pub struct MergeView {
572 pub mode: String,
574 pub state: &'static str,
576 pub detail: String,
578 pub branch: Option<String>,
580 pub squash_caveat: bool,
582 pub red_at_merge: Vec<String>,
584}
585
586#[derive(Debug, Serialize)]
588pub struct FollowupRow {
589 pub task: String,
591 pub findings: Vec<String>,
593}
594
595#[derive(Debug, Serialize)]
597pub struct ReleaseBumpView {
598 pub version: Option<String>,
600 pub pr_url: Option<String>,
602 pub automerge: &'static str,
604 pub skipped: Option<String>,
606 pub problem: Option<String>,
608 pub action_required: Option<String>,
610}
611
612#[derive(Debug, Serialize)]
614pub struct JobGroup {
615 pub node: String,
617 pub seat: String,
619 pub jobs: Vec<JobRow>,
621}
622
623#[derive(Debug, Serialize)]
625pub struct JobRow {
626 pub id: String,
628 pub status: &'static str,
630 pub exit_code: Option<i32>,
632 pub round: Option<usize>,
634 pub checked_at: String,
636 pub description: String,
638 pub result_summary: String,
640}
641
642fn short(commit: &str) -> String {
643 commit.chars().take(7).collect()
644}
645
646fn first_line(text: &str) -> String {
647 text.lines()
648 .map(str::trim)
649 .find(|l| !l.is_empty())
650 .unwrap_or("")
651 .to_owned()
652}
653
654fn severity_name(s: Severity) -> &'static str {
655 match s {
656 Severity::Nit => "nit",
657 Severity::Minor => "minor",
658 Severity::Major => "major",
659 Severity::Blocker => "blocker",
660 }
661}
662
663fn vote_name(v: ReviewVote) -> &'static str {
664 match v {
665 ReviewVote::Approve => "approve",
666 ReviewVote::ApproveWithFindings => "approve_with_findings",
667 ReviewVote::Reject => "reject",
668 }
669}
670
671fn command(o: &CommandOutcome, warn_instead_of_fail: bool) -> CommandView {
672 let state = if o.resource_blocked {
673 "blocked"
674 } else if o.ok() {
675 "pass"
676 } else if warn_instead_of_fail {
677 "warn"
678 } else {
679 "fail"
680 };
681 CommandView {
682 command: o.command.clone(),
683 state,
684 code: o.code,
685 duration_ms: o.duration_ms,
686 output_tail: (!o.ok()).then(|| crate::run::summarize_verify(&o.output_tail, OUTPUT_TAIL)),
687 }
688}
689
690fn status_tone(state: &RunState) -> Tone {
691 use crate::run::RunStatus as S;
692 if state.needs_attention() {
693 return Tone::Warn;
694 }
695 match state.status {
696 S::Merged | S::Ready => Tone::Ok,
697 S::Failed => Tone::Fail,
698 S::Stalled | S::Blocked => Tone::Warn,
699 _ => Tone::Neutral,
700 }
701}
702
703fn round_view(r: &ReviewRound, last: bool) -> RoundView {
704 let raised = r.reviews.iter().map(|x| x.findings.len()).sum();
705 let status = if r.incomplete() {
706 "incomplete"
707 } else if r.clean {
708 "clean"
709 } else {
710 "open"
711 };
712 let e2e_state = match r.e2e_status() {
713 E2eStatus::NotConfigured => "not_configured",
714 E2eStatus::Deferred => "deferred",
715 E2eStatus::Passed => "pass",
716 E2eStatus::Failed => "fail",
717 E2eStatus::ResourceBlocked => "blocked",
718 };
719 let e2e = E2eView {
720 state: e2e_state,
721 build_failed: r.e2e_status() == E2eStatus::Failed
722 && r.e2e.iter().any(CommandOutcome::build_failed),
723 defer_reason: r
724 .e2e_defer_reason
725 .clone()
726 .filter(|_| e2e_state == "deferred"),
727 retried: r.verify_retried,
728 };
729 let tone = if e2e_state == "fail" && !e2e.build_failed {
730 Tone::Fail
731 } else if status == "clean" {
732 Tone::Ok
733 } else {
734 Tone::Warn
735 };
736 let missing = if r.incomplete() {
737 r.reviews
738 .iter()
739 .filter_map(|x| {
740 x.failed
741 .as_ref()
742 .map(|why| format!("review-{}: {why}", x.reviewer))
743 })
744 .collect()
745 } else {
746 Vec::new()
747 };
748 let fix = r.fix.as_ref().map(|f| FixView {
749 agent: f.agent.clone(),
750 addressed: f.addressed.len(),
751 rejected: f.rejected.len(),
752 committed: f.committed,
753 tree_changed: r.progressed,
754 report_lost: f.failed.clone(),
755 continuation: f.continuation.as_ref().and_then(|c| match c.outcome {
756 ContinuationOutcome::NotNeeded => None,
757 ContinuationOutcome::Resumed => Some(format!("resumed x{}", c.attempts)),
758 ContinuationOutcome::Exhausted => Some(format!("exhausted x{}", c.attempts)),
759 ContinuationOutcome::QuotaLost => Some("quota".to_owned()),
760 ContinuationOutcome::NoSession => Some("no session to resume".to_owned()),
761 }),
762 });
763
764 let mut findings: Vec<FindingView> = Vec::new();
765 for rec in &r.reviews {
766 for f in &rec.findings {
767 findings.push(finding_view(r, rec.reviewer, f));
768 }
769 }
770 if let Some(fix) = &r.fix {
774 for rej in &fix.rejected {
775 if !findings.iter().any(|f| f.id == rej.id) {
776 findings.push(FindingView {
777 id: rej.id.clone(),
778 reviewer: None,
779 severity: None,
780 title: String::new(),
781 file: None,
782 line: None,
783 blocking: false,
784 state: "declined",
785 declined_why: Some(rej.why.clone()),
786 });
787 }
788 }
789 }
790
791 RoundView {
792 round: r.round,
793 status,
794 tone,
795 head: short(&r.head),
796 verified_head: r.verified_head.as_deref().map(short),
797 raised,
798 blocking: r.blocking,
799 answered: r.answered,
800 expected: r.expected,
801 missing,
802 e2e,
803 verdict: r.verdict.map(|v| VerdictView {
804 vote: vote_name(v),
805 label: v.label(),
806 split: r.vote_split,
807 }),
808 fix,
809 commands: r.e2e.iter().map(|o| command(o, false)).collect(),
810 reviewers: r
811 .reviews
812 .iter()
813 .map(|x| ReviewerRow {
814 reviewer: x.reviewer,
815 agent: x.agent.clone(),
816 vote: x.vote.map(vote_name),
817 failed: x.failed.clone(),
818 })
819 .collect(),
820 findings,
821 reconsideration: r
822 .reconsideration
823 .iter()
824 .map(|rv| RevoteRow {
825 reviewer: rv.reviewer,
826 vote: rv.vote.map(vote_name),
827 reason: match rv.vote {
828 Some(_) => rv.reason.clone(),
829 None => format!("no revote ({})", rv.failed.as_deref().unwrap_or("unknown")),
830 },
831 })
832 .collect(),
833 default_open: last || status != "clean",
834 }
835}
836
837fn finding_view(r: &ReviewRound, reviewer: usize, f: &Finding) -> FindingView {
838 let fixed = r
839 .fix
840 .as_ref()
841 .is_some_and(|fix| fix.addressed.contains(&f.id));
842 let declined = r
843 .fix
844 .as_ref()
845 .and_then(|fix| fix.rejected.iter().find(|x| x.id == f.id));
846 let (state, declined_why) = match (fixed, declined) {
847 (true, _) => ("fixed", None),
848 (false, Some(rej)) => ("declined", Some(rej.why.clone())),
849 (false, None) => ("open", None),
850 };
851 FindingView {
852 id: f.id.clone(),
853 reviewer: Some(reviewer),
854 severity: Some(severity_name(f.severity)),
855 title: f.title.clone(),
856 file: f.file.clone(),
857 line: f.line,
858 blocking: f.severity.blocks(),
859 state,
860 declined_why,
861 }
862}
863
864fn jobs_section(state: &RunState) -> Option<Section> {
865 let codex = state
866 .config
867 .agents
868 .iter()
869 .any(|a| a.kind == crate::config::AgentKind::Codex);
870 if state.jobs.is_empty() && !codex {
871 return None;
872 }
873 let mut by_seat: BTreeMap<(&str, &str), Vec<&crate::run::JobRecord>> = BTreeMap::new();
874 for j in &state.jobs {
875 by_seat
876 .entry((j.node.as_str(), j.seat.as_str()))
877 .or_default()
878 .push(j);
879 }
880 let groups = by_seat
881 .into_iter()
882 .map(|((node, seat), records)| JobGroup {
883 node: node.to_owned(),
884 seat: seat.to_owned(),
885 jobs: records
886 .into_iter()
887 .map(|j| JobRow {
888 id: j.id.clone(),
889 status: match j.status {
890 JobStatus::Completed => "completed",
891 JobStatus::Failed => "failed",
892 JobStatus::Unknown => "unknown",
893 },
894 exit_code: j.exit_code,
895 round: j.round,
896 checked_at: j.checked_at.to_string(),
897 description: first_line(&j.description),
898 result_summary: tail(&j.result_summary, OUTPUT_TAIL),
899 })
900 .collect(),
901 })
902 .collect();
903 Some(Section::Jobs {
904 head: head("background jobs", Tone::Neutral, false),
905 groups,
906 coverage: "adapter coverage: codex only today; other backends, and a command a CLI \
907 never reported finishing, leave no entry here - that is unknown, never \
908 \"nothing ran\"",
909 empty_note: state.jobs.is_empty().then_some(
910 "no completed command evidence yet for this run (see active seats for what is \
911 still mid-turn)",
912 ),
913 })
914}
915
916#[derive(Debug, Serialize)]
918pub struct GraftStrengthView {
919 pub id: String,
921 pub source: String,
923 pub text: String,
925 pub state: &'static str,
928 pub why: Option<String>,
930}
931
932#[derive(Debug, Serialize)]
934pub struct GraftHandoverView {
935 pub from: String,
937 pub to: String,
939 pub reason: String,
941}
942
943#[derive(Debug, Serialize)]
945pub struct GraftView {
946 pub outcome: &'static str,
948 pub reason: Option<String>,
950 pub agent: Option<String>,
952 pub handovers: Vec<GraftHandoverView>,
954 pub winner: Option<String>,
956 pub sources: Vec<String>,
958 pub lines: Option<usize>,
960 pub requested: Vec<GraftStrengthView>,
962 pub adopted_count: usize,
964 pub rejected_count: usize,
966 pub unconfirmed_count: usize,
968 pub notes_md: Vec<crate::md::Node>,
970 pub reasoning_md: Vec<crate::md::Node>,
972 pub recovered: bool,
974 pub unavailable: Vec<crate::graft_report::Unavailable>,
976 pub diff_available: bool,
978 pub pre: String,
980 pub post: Option<String>,
982}
983
984fn graft_view(
985 state: &RunState,
986 g: &crate::run::GraftRecord,
987 rec: Option<&crate::graft_report::Recovered>,
988) -> GraftView {
989 use crate::graft_report::{Standing, Unavailable, standing};
990 use crate::run::GraftOutcome;
991 let mut unavailable: Vec<Unavailable> = Vec::new();
992 let mut recovered = false;
993 let requested_src: Vec<_> = if g.requested.is_empty() {
994 match rec {
995 Some(r) => {
996 recovered |= !r.requested.is_empty();
997 r.requested.clone()
998 }
999 None => Vec::new(),
1000 }
1001 } else {
1002 g.requested.clone()
1003 };
1004 let adoption = match (&g.adoption, rec) {
1005 (Some(a), _) => Some(a.clone()),
1006 (None, Some(r)) if g.adoption_error.is_none() => {
1007 recovered |= r.adoption.is_some();
1008 r.adoption.clone()
1009 }
1010 _ => None,
1011 };
1012 if let Some(r) = rec {
1013 unavailable.extend(r.unavailable.iter().cloned());
1014 }
1015 if let Some(e) = &g.adoption_error {
1016 unavailable.push(Unavailable {
1017 what: "adoption",
1018 reason: e.clone(),
1019 });
1020 }
1021 if g.requested.is_empty() && requested_src.is_empty() && rec.is_none() {
1022 unavailable.push(Unavailable {
1023 what: "requested",
1024 reason: "legacy_record".to_owned(),
1025 });
1026 }
1027 if adoption.is_none() && g.adoption_error.is_none() && rec.is_none() {
1028 unavailable.push(Unavailable {
1029 what: "adoption",
1030 reason: "legacy_record".to_owned(),
1031 });
1032 }
1033 let requested: Vec<GraftStrengthView> = requested_src
1034 .iter()
1035 .map(|s| {
1036 let (st, why) = match adoption.as_ref().map(|a| standing(s, a)) {
1037 None => ("unknown", None),
1038 Some(Standing::Adopted) => ("adopted", None),
1039 Some(Standing::Rejected(w)) => ("rejected", Some(w).filter(|w| !w.is_empty())),
1040 Some(Standing::Unreported) => ("unreported", None),
1041 };
1042 GraftStrengthView {
1043 id: s.id.clone(),
1044 source: s.source.to_string(),
1045 text: s.text.clone(),
1046 state: st,
1047 why,
1048 }
1049 })
1050 .collect();
1051 let count = |st: &str| requested.iter().filter(|r| r.state == st).count();
1052 let (adopted_count, rejected_count) = (count("adopted"), count("rejected"));
1053 let mut sources: Vec<String> = requested.iter().map(|r| r.source.clone()).collect();
1054 sources.dedup();
1055 let (outcome, reason) = match &g.outcome {
1056 GraftOutcome::Applied => ("applied", None),
1057 GraftOutcome::WaitingForRestore => ("waiting_for_restore", None),
1058 GraftOutcome::Running => ("unfinished", None),
1059 GraftOutcome::RolledBack(w) => ("rolled_back", Some(w.clone())),
1060 };
1061 let md = |t: &str| {
1062 if t.trim().is_empty() {
1063 Vec::new()
1064 } else {
1065 crate::md::to_nodes(t, &crate::md::ImageBase::None)
1066 }
1067 };
1068 let short = |c: &str| c.chars().take(12).collect::<String>();
1069 if outcome == "applied" && g.post.is_none() {
1070 unavailable.push(Unavailable {
1071 what: "diff",
1072 reason: "no_post_commit".to_owned(),
1073 });
1074 }
1075 GraftView {
1076 outcome,
1077 reason,
1078 agent: g.agent.clone(),
1079 handovers: state
1080 .handovers
1081 .iter()
1082 .filter(|h| h.node == "graft")
1083 .map(|h| GraftHandoverView {
1084 from: h.from.clone(),
1085 to: h.to.clone(),
1086 reason: h.reason.clone(),
1087 })
1088 .collect(),
1089 winner: state.tally.as_ref().map(|t| t.winner.to_string()),
1090 sources,
1091 lines: g.lines,
1092 unconfirmed_count: requested.len() - adopted_count - rejected_count,
1093 adopted_count,
1094 rejected_count,
1095 requested,
1096 notes_md: adoption.as_ref().map(|a| md(&a.notes)).unwrap_or_default(),
1097 reasoning_md: adoption
1098 .as_ref()
1099 .map(|a| md(&a.reasoning))
1100 .unwrap_or_default(),
1101 recovered,
1102 unavailable,
1103 diff_available: g.post.is_some(),
1104 pre: short(&g.pre),
1105 post: g.post.as_deref().map(short),
1106 }
1107}
1108
1109pub fn build(state: &RunState, live: Liveness) -> RunReportView {
1112 build_with(state, live, None)
1113}
1114
1115pub fn build_with(
1118 state: &RunState,
1119 live: Liveness,
1120 graft: Option<&crate::graft_report::Recovered>,
1121) -> RunReportView {
1122 let mut sections: Vec<Section> = Vec::new();
1123 let met_quorum = state.tally.as_ref().is_none_or(|t| t.met_quorum);
1124
1125 sections.push(Section::Candidates {
1126 head: head(
1127 "candidates",
1128 if state.candidates.iter().any(|c| c.failed.is_some()) {
1129 Tone::Warn
1130 } else {
1131 Tone::Neutral
1132 },
1133 true,
1134 ),
1135 items: state
1136 .candidates
1137 .iter()
1138 .map(|c| {
1139 let is_winner = state.tally.as_ref().is_some_and(|t| t.winner == c.label);
1140 let (st, detail) = match (&c.failed, c.empty, &c.verified_noop) {
1141 (Some(e), _, _) => ("failed", Some(e.clone())),
1142 (None, true, Some(ev)) => ("verified_noop", Some(first_line(ev))),
1143 (None, true, None) => ("no_change", None),
1144 _ => (
1145 "changed",
1146 Some(first_line(&c.summary)).filter(|s| !s.is_empty()),
1147 ),
1148 };
1149 CandidateRow {
1150 label: c.label.to_string(),
1151 agent: c.agent.clone(),
1152 state: st,
1153 detail,
1154 files: c.files,
1155 commits: c.commits,
1156 duration_secs: c.duration_ms / 1000,
1157 winner: is_winner && met_quorum,
1158 provisional_winner: is_winner && !met_quorum,
1159 }
1160 })
1161 .collect(),
1162 });
1163
1164 if !state.judgements.is_empty() {
1165 sections.push(Section::Judging {
1166 head: head("blind judging", Tone::Neutral, false),
1167 judges: state
1168 .judgements
1169 .iter()
1170 .map(|j| JudgeRow {
1171 judge: j.judge,
1172 agent: j.agent.clone(),
1173 ranking: j.ranking.iter().collect(),
1174 confidence: j.confidence,
1175 failed: j.failed.clone(),
1176 })
1177 .collect(),
1178 deliberation: state
1179 .tally
1180 .as_ref()
1181 .filter(|t| t.deliberated)
1182 .map(|_| {
1183 state
1184 .deliberation
1185 .iter()
1186 .flat_map(|round| {
1187 round.turns.iter().map(move |turn| {
1188 format!(
1189 "r{} judge {} -> {}",
1190 round.round,
1191 turn.judge,
1192 turn.tentative.map_or("-".to_owned(), |c| c.to_string())
1193 )
1194 })
1195 })
1196 .collect()
1197 })
1198 .unwrap_or_default(),
1199 rounds: state
1200 .deliberation
1201 .iter()
1202 .map(|r| DeliberationRoundView {
1203 round: r.round,
1204 turns: r
1205 .turns
1206 .iter()
1207 .map(|t| DeliberationTurnView {
1208 judge: t.judge,
1209 agent: t.agent.clone(),
1210 tentative: t.tentative.map(|c| c.to_string()),
1211 body: t.body.clone(),
1212 body_md: crate::md::to_nodes(&t.body, &crate::md::ImageBase::None),
1213 })
1214 .collect(),
1215 })
1216 .collect(),
1217 votes: state
1218 .votes
1219 .iter()
1220 .map(|v| VoteRow {
1221 judge: v.judge,
1222 agent: v.agent.clone(),
1223 vote: v.vote.unwrap_or('?').to_string(),
1224 changed: v.changed,
1225 })
1226 .collect(),
1227 });
1228 }
1229
1230 if let Some(t) = &state.tally {
1231 let initial = match (t.rankings, t.unanimous_initial) {
1232 (0, _) => "none",
1233 (1, _) => "one",
1234 (_, true) => "unanimous",
1235 (_, false) => "split",
1236 };
1237 sections.push(Section::Tally {
1238 head: head(
1239 "tally",
1240 if t.met_quorum { Tone::Ok } else { Tone::Fail },
1241 true,
1242 ),
1243 tally: TallyView {
1244 met_quorum: t.met_quorum,
1245 decided: t.met_quorum,
1246 provisional: !t.met_quorum,
1247 winner: t.winner.to_string(),
1248 judges: t.judges,
1249 present: t.present,
1250 quorum: t.quorum,
1251 uncontested: t.uncontested.clone(),
1252 first_choice: t
1253 .first_choice
1254 .iter()
1255 .map(|(k, v)| (k.to_string(), *v))
1256 .collect(),
1257 initial,
1258 unanimous_final: t.unanimous_final,
1259 changed_votes: t.changed_votes,
1260 tie_break: t.tie_break.clone(),
1261 rate_limited: state.quota.iter().map(|q| q.seat.clone()).collect(),
1262 },
1263 });
1264 }
1265
1266 let notes = |key: &'static str, title: &str, tone: Tone, lines: Vec<String>| {
1267 (!lines.is_empty()).then(|| Section::Notes {
1268 head: head(title, tone, true),
1269 key,
1270 lines,
1271 })
1272 };
1273 sections.extend(notes(
1274 "handovers",
1275 "seats handed over",
1276 Tone::Warn,
1277 state
1278 .handovers
1279 .iter()
1280 .map(|h| {
1281 format!(
1282 "{} {} -> {} ({}: {})",
1283 h.seat, h.from, h.to, h.node, h.reason
1284 )
1285 })
1286 .collect(),
1287 ));
1288 sections.extend(notes(
1289 "withheld",
1290 "withheld from commit",
1291 Tone::Warn,
1292 state
1293 .withheld
1294 .iter()
1295 .map(|w| {
1296 format!(
1297 "{} ({} lockfile; the directory uses {})",
1298 w.path, w.manager, w.kept_by
1299 )
1300 })
1301 .collect(),
1302 ));
1303
1304 if let Some(g) = &state.graft {
1305 let view = graft_view(state, g, graft);
1306 let tone = match view.outcome {
1307 "applied" if view.unconfirmed_count == 0 && !view.requested.is_empty() => Tone::Ok,
1308 "applied" => Tone::Neutral,
1309 _ => Tone::Warn,
1310 };
1311 sections.push(Section::Graft {
1312 head: head("graft", tone, true),
1313 graft: view,
1314 });
1315 }
1316
1317 if !state.reviews.is_empty() {
1318 let n = state.reviews.len();
1319 let rounds: Vec<RoundView> = state
1320 .reviews
1321 .iter()
1322 .enumerate()
1323 .map(|(i, r)| round_view(r, i + 1 == n))
1324 .collect();
1325 let handed_off = state.handed_off_with_open_findings().then(|| {
1326 format!(
1327 "handed off with {} finding(s) still open - gate and e2e were green; see above \
1328 for what a person should still look at",
1329 state.open_findings().len()
1330 )
1331 });
1332 let tone = if handed_off.is_some() {
1333 Tone::Warn
1334 } else {
1335 rounds.last().map_or(Tone::Neutral, |r| r.tone)
1336 };
1337 sections.push(Section::Review {
1338 head: head("review + verification", tone, true),
1339 rounds,
1340 handed_off,
1341 });
1342 }
1343
1344 if !state.operator_fixes.is_empty() {
1345 sections.push(Section::OperatorFixes {
1346 head: head("operator fix(es)", Tone::Neutral, true),
1347 requests: state
1348 .operator_fixes
1349 .iter()
1350 .map(|req| OperatorFixView {
1351 requested_at: req.requested_at.to_string(),
1352 stale: req.stale,
1353 reason: req.reason.clone(),
1354 findings: req
1355 .findings
1356 .iter()
1357 .map(|f| {
1358 let (outcome, why) = match &f.outcome {
1359 OperatorFixOutcome::Pending => ("pending", None),
1360 OperatorFixOutcome::Addressed => ("addressed", None),
1361 OperatorFixOutcome::Rejected { why } => {
1362 ("rejected", Some(why.clone()))
1363 }
1364 OperatorFixOutcome::Unreported => ("unreported", None),
1365 };
1366 OperatorFindingRow {
1367 id: f.id.clone(),
1368 severity: severity_name(f.severity),
1369 title: f.title.clone(),
1370 outcome,
1371 why,
1372 }
1373 })
1374 .collect(),
1375 follow_up_review_run: req.follow_up_review_run.clone(),
1376 unverified_commit: req.follow_up_review_run.is_none()
1377 && req.fix.as_ref().is_some_and(|fx| fx.committed),
1378 })
1379 .collect(),
1380 });
1381 }
1382
1383 if let Some(bs) = &state.base_sync {
1384 let (st, tone) = if bs.already_in.is_some() {
1385 ("already_in", Tone::Neutral)
1386 } else if state.resource_block.is_some() {
1387 ("resource_wait", Tone::Warn)
1388 } else if bs.conflict.is_some() {
1389 (
1390 match state.base_sync_recovery.stop.as_ref().map(|s| &s.kind) {
1391 Some(crate::base_sync_recovery::StopKind::Exhausted) => "exhausted",
1392 Some(crate::base_sync_recovery::StopKind::Publication) => "publication",
1393 Some(crate::base_sync_recovery::StopKind::Divergence) => "divergence",
1394 Some(crate::base_sync_recovery::StopKind::Conflict) => "conflict",
1395 _ => "stopped",
1396 },
1397 Tone::Fail,
1398 )
1399 } else if bs.behind == 0 {
1400 ("in_sync", Tone::Ok)
1401 } else {
1402 ("behind", Tone::Warn)
1403 };
1404 sections.push(Section::BaseSync {
1405 head: head("base sync", tone, true),
1406 base_sync: BaseSyncView {
1407 base_branch: state.base_branch.clone(),
1408 tip: short(&bs.tip),
1409 state: st,
1410 behind: bs.behind,
1411 attempts: bs.attempts,
1412 episode_used: state.base_episode_used(),
1413 episode_limit: crate::graph::BASE_SYNC_ROUNDS,
1414 next_action: state.base_sync_next_action(),
1415 conflict: bs.conflict.as_deref().map(first_line),
1416 already_in: bs
1417 .already_in
1418 .as_ref()
1419 .map(|a| format!("{} ({} match)", a.names(), a.proof.as_str())),
1420 },
1421 });
1422 }
1423
1424 if !state.pre_gate.is_empty() {
1425 let all_ok = state.pre_gate.iter().all(CommandOutcome::ok);
1426 sections.push(Section::PreGate {
1427 head: head(
1428 "pre_gate",
1429 if all_ok { Tone::Ok } else { Tone::Warn },
1430 !all_ok,
1431 ),
1432 commands: state.pre_gate.iter().map(|o| command(o, true)).collect(),
1433 committed: state.pre_gate_commit.as_deref().map(short),
1434 });
1435 }
1436
1437 match state.gate_status() {
1438 GateStatus::NotRun => {}
1439 gs => {
1440 let st = match gs {
1441 GateStatus::NotConfigured => "not_configured",
1442 GateStatus::Passed => "passed",
1443 _ => "failed",
1444 };
1445 sections.push(Section::Gate {
1446 head: head(
1447 "gate",
1448 if gs == GateStatus::NotConfigured {
1449 Tone::Neutral
1450 } else if gs.ok() {
1451 Tone::Ok
1452 } else {
1453 Tone::Fail
1454 },
1455 true,
1456 ),
1457 status: st,
1458 commands: state.gate.iter().map(|o| command(o, false)).collect(),
1459 });
1460 }
1461 }
1462
1463 if let Some(m) = &state.merge {
1464 let by_design = m.mode == MergeMode::None;
1465 let (st, tone) = if by_design {
1466 ("not_landed_by_design", Tone::Neutral)
1467 } else if m.empty {
1468 ("empty", Tone::Warn)
1469 } else if m.ok && state.status == crate::run::RunStatus::Merged {
1470 ("ok", Tone::Ok)
1471 } else if m.ok && m.mode == MergeMode::Pr {
1472 ("published", Tone::Neutral)
1473 } else {
1474 ("not_merged", Tone::Warn)
1475 };
1476 sections.push(Section::Merge {
1477 head: head("merge", tone, true),
1478 merge: MergeView {
1479 mode: format!("{:?}", m.mode).to_lowercase(),
1480 state: st,
1481 detail: m.detail.lines().next().unwrap_or("").to_owned(),
1482 branch: by_design
1483 .then(|| state.winner().map(|w| w.branch.clone()))
1484 .flatten(),
1485 squash_caveat: by_design && state.config.merge.style == MergeStyle::Squash,
1486 red_at_merge: state
1487 .pr
1488 .as_ref()
1489 .map(|pr| pr.red_at_merge.clone())
1490 .unwrap_or_default(),
1491 },
1492 });
1493 }
1494
1495 if !state.followups.is_empty() {
1496 sections.push(Section::Followups {
1497 head: head("follow-ups", Tone::Neutral, true),
1498 items: state
1499 .followups
1500 .iter()
1501 .map(|f| FollowupRow {
1502 task: crate::queue::short(&f.task).to_owned(),
1503 findings: f.findings.clone(),
1504 })
1505 .collect(),
1506 });
1507 }
1508
1509 if let Some(b) = &state.release_bump {
1510 let automerge = if b.skipped.is_some() && b.pr_url.is_none() {
1511 "skipped"
1512 } else if b.automerge_enabled {
1513 "enabled"
1514 } else if b.merged_directly {
1515 "merged_directly"
1516 } else if b.local {
1517 "local"
1518 } else if b.problem.is_some() {
1519 "failed"
1520 } else {
1521 "not_enabled"
1522 };
1523 sections.push(Section::ReleaseBump {
1524 head: head(
1525 "release bump",
1526 if automerge == "failed" || automerge == "skipped" || b.action_required.is_some() {
1527 Tone::Warn
1528 } else {
1529 Tone::Neutral
1530 },
1531 true,
1532 ),
1533 bump: ReleaseBumpView {
1534 version: b.version.clone(),
1535 pr_url: b.pr_url.clone(),
1536 automerge,
1537 problem: b.problem.as_deref().map(first_line),
1538 action_required: b.action_required.clone(),
1539 skipped: b.skipped.clone(),
1540 },
1541 });
1542 }
1543
1544 sections.extend(notes(
1545 "leaks",
1546 "blindness warnings",
1547 Tone::Warn,
1548 state
1549 .leaks
1550 .iter()
1551 .map(|l| format!("{} x{} in {}", l.token, l.count, l.site))
1552 .collect(),
1553 ));
1554
1555 if let Some(w) = state.winner()
1556 && !w.folded
1557 {
1558 sections.push(Section::WinnerWorktree {
1559 head: head("winner worktree", Tone::Neutral, true),
1560 worktree: w.worktree.display().to_string(),
1561 branch: w.branch.clone(),
1562 provisional: !met_quorum,
1563 });
1564 }
1565
1566 sections.extend(jobs_section(state));
1567
1568 RunReportView {
1569 schema: SCHEMA,
1570 header: Header {
1571 id: state.id.clone(),
1572 status: state.status.display_label().to_owned(),
1573 tone: status_tone(state),
1574 unmerged_by_design: state.unmerged_by_design(),
1575 needs_attention: state.needs_attention(),
1576 repo: state.repo.display().to_string(),
1577 base_branch: state.base_branch.clone(),
1578 base_commit: short(&state.base_commit),
1579 created: state.created_local(),
1580 task: first_line(&state.instruction),
1581 origin: crate::run::origin_label(state.origin.as_ref()),
1582 state_dir: state.dir().display().to_string(),
1583 },
1584 sections,
1585 active_seats: crate::report::active_seats(state, live),
1586 }
1587}
1588
1589#[cfg(test)]
1590mod tests {
1591 use super::*;
1592 use crate::config::Config;
1593 use crate::run::{CommandOutcome, FixRecord, ReviewRecord, Tally};
1594 use crate::verdict::Rejection;
1595 use std::path::PathBuf;
1596
1597 fn state() -> RunState {
1598 crate::run::pin_test_home();
1600 RunState::new(
1601 PathBuf::from("/repo/magi"),
1602 "main".to_owned(),
1603 "0123456789abcdef".to_owned(),
1604 "Add a thing\n\nlong".to_owned(),
1605 Config::default(),
1606 )
1607 }
1608
1609 fn finding(id: &str, severity: Severity) -> Finding {
1610 Finding {
1611 id: id.to_owned(),
1612 severity,
1613 file: Some("src/a.rs".to_owned()),
1614 line: Some(7),
1615 title: format!("title {id}"),
1616 detail: String::new(),
1617 }
1618 }
1619
1620 fn cmd(code: Option<i32>, blocked: bool) -> CommandOutcome {
1621 CommandOutcome {
1622 command: "cargo test".to_owned(),
1623 code,
1624 output_tail: "boom\n".repeat(1_000),
1625 duration_ms: 5,
1626 resource_blocked: blocked,
1627 }
1628 }
1629
1630 fn round(n: usize) -> ReviewRound {
1631 let mut r: ReviewRound = serde_json::from_value(serde_json::json!({
1632 "round": n, "head": "abcdef0123", "reviews": []
1633 }))
1634 .expect("round");
1635 r.answered = 1;
1636 r.expected = 1;
1637 r.reviews.push(
1638 serde_json::from_value::<ReviewRecord>(serde_json::json!({
1639 "reviewer": 1, "agent": "a"
1640 }))
1641 .expect("record"),
1642 );
1643 r
1644 }
1645
1646 fn tally(met: bool) -> Tally {
1647 serde_json::from_value(serde_json::json!({
1648 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
1649 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
1650 "unanimous_final": true, "judges": 3, "present": if met { 3 } else { 1 },
1651 "quorum": 2, "met_quorum": met, "rankings": 1
1652 }))
1653 .expect("tally")
1654 }
1655
1656 fn kinds(v: &RunReportView) -> Vec<String> {
1657 serde_json::to_value(&v.sections)
1658 .expect("json")
1659 .as_array()
1660 .expect("array")
1661 .iter()
1662 .map(|s| s["kind"].as_str().expect("kind").to_owned())
1663 .collect()
1664 }
1665
1666 #[test]
1667 fn sections_follow_the_text_report_order() {
1668 let mut s = state();
1669 s.tally = Some(tally(true));
1670 s.reviews.push(round(1));
1671 s.base_sync = Some(
1672 serde_json::from_value(serde_json::json!({"tip": "abc", "behind": 0, "attempts": 0}))
1673 .expect("sync"),
1674 );
1675 s.pre_gate.push(cmd(Some(0), false));
1676 s.jobs.push(
1677 serde_json::from_value(serde_json::json!({
1678 "node": "fix", "seat": "fix", "id": "j1", "description": "ls",
1679 "checked_at": "2026-01-01T00:00:00Z", "status": "Completed",
1680 "exit_code": 0, "source": "codex"
1681 }))
1682 .expect("job"),
1683 );
1684 let v = build(&s, Liveness::Unknown);
1685 assert_eq!(
1686 kinds(&v),
1687 [
1688 "candidates",
1689 "tally",
1690 "review",
1691 "base_sync",
1692 "pre_gate",
1693 "jobs"
1694 ]
1695 );
1696 let json = serde_json::to_value(&v).expect("json");
1697 assert_eq!(json["schema"], SCHEMA);
1698 let jobs = json["sections"].as_array().expect("a").last().expect("l");
1699 assert_eq!(jobs["default_open"], false, "jobs start collapsed");
1700 assert_eq!(jobs["groups"][0]["jobs"][0]["status"], "completed");
1701 }
1702
1703 #[test]
1704 fn a_stalled_tally_is_never_decided() {
1705 let mut s = state();
1706 s.tally = Some(tally(false));
1707 let json = serde_json::to_value(build(&s, Liveness::Unknown)).expect("json");
1708 let t = json["sections"]
1709 .as_array()
1710 .expect("a")
1711 .iter()
1712 .find(|x| x["kind"] == "tally")
1713 .expect("tally");
1714 assert_eq!(t["tally"]["met_quorum"], false);
1715 assert_eq!(t["tally"]["decided"], false);
1716 assert_eq!(t["tally"]["provisional"], true);
1717 assert_eq!(t["tone"], "fail");
1718 }
1719
1720 #[test]
1721 fn findings_keep_severity_blocking_and_what_the_fixer_said() {
1722 let mut s = state();
1723 let mut r = round(1);
1724 r.reviews[0].findings = vec![
1725 finding("R1-1-1", Severity::Blocker),
1726 finding("R1-1-2", Severity::Major),
1727 finding("R1-1-3", Severity::Minor),
1728 finding("R1-1-4", Severity::Nit),
1729 ];
1730 r.fix = Some(
1731 serde_json::from_value::<FixRecord>(serde_json::json!({"agent": "f"})).expect("fix"),
1732 );
1733 let fix = r.fix.as_mut().expect("fix");
1734 fix.addressed = vec!["R1-1-1".to_owned()];
1735 fix.rejected = vec![
1736 Rejection {
1737 id: "R1-1-2".to_owned(),
1738 why: "not a bug".to_owned(),
1739 },
1740 Rejection {
1741 id: "R9-9-9".to_owned(),
1742 why: "unknown id".to_owned(),
1743 },
1744 ];
1745 s.reviews.push(r);
1746 let json = serde_json::to_value(build(&s, Liveness::Unknown)).expect("json");
1747 let review = json["sections"]
1748 .as_array()
1749 .expect("a")
1750 .iter()
1751 .find(|x| x["kind"] == "review")
1752 .expect("review");
1753 let f = &review["rounds"][0]["findings"];
1754 let at = |i: usize| {
1755 (
1756 f[i]["severity"].clone(),
1757 f[i]["blocking"].clone(),
1758 f[i]["state"].clone(),
1759 )
1760 };
1761 assert_eq!(at(0), ("blocker".into(), true.into(), "fixed".into()));
1762 assert_eq!(at(1), ("major".into(), true.into(), "declined".into()));
1763 assert_eq!(f[1]["declined_why"], "not a bug");
1764 assert_eq!(at(2), ("minor".into(), false.into(), "open".into()));
1765 assert_eq!(at(3), ("nit".into(), false.into(), "open".into()));
1766 assert_eq!(
1767 f[4]["id"], "R9-9-9",
1768 "an unmatched declined id is not dropped"
1769 );
1770 assert_eq!(f[4]["state"], "declined");
1771 }
1772
1773 #[test]
1774 fn a_deferred_e2e_is_not_a_failed_one() {
1775 let mut s = state();
1776 let mut deferred = round(1);
1777 deferred.e2e_deferred = true;
1778 deferred.e2e_defer_reason = Some("blocking findings".to_owned());
1779 let mut failed = round(2);
1780 failed.e2e = vec![cmd(Some(1), false)];
1781 let mut blocked = round(3);
1782 blocked.e2e = vec![cmd(None, true)];
1783 let none = round(4);
1784 s.reviews = vec![deferred, failed, blocked, none];
1785 let json = serde_json::to_value(build(&s, Liveness::Unknown)).expect("json");
1786 let rounds = &json["sections"]
1787 .as_array()
1788 .expect("a")
1789 .iter()
1790 .find(|x| x["kind"] == "review")
1791 .expect("review")["rounds"];
1792 let st = |i: usize| {
1793 rounds[i]["e2e"]["state"]
1794 .as_str()
1795 .expect("state")
1796 .to_owned()
1797 };
1798 assert_eq!(
1799 [st(0), st(1), st(2), st(3)],
1800 ["deferred", "fail", "blocked", "not_configured"]
1801 );
1802 assert_eq!(rounds[0]["e2e"]["defer_reason"], "blocking findings");
1803 let tail = rounds[1]["commands"][0]["output_tail"]
1804 .as_str()
1805 .expect("tail");
1806 assert!(tail.len() < 2_100, "output is bounded: {}", tail.len());
1807 }
1808 fn graft_json(s: &RunState, rec: Option<&crate::graft_report::Recovered>) -> serde_json::Value {
1809 let json = serde_json::to_value(build_with(s, Liveness::Unknown, rec)).expect("json");
1810 json["sections"]
1811 .as_array()
1812 .expect("a")
1813 .iter()
1814 .find(|x| x["kind"] == "graft")
1815 .cloned()
1816 .unwrap_or(serde_json::Value::Null)
1817 }
1818
1819 fn graft_record(outcome: crate::run::GraftOutcome) -> crate::run::GraftRecord {
1820 use crate::run::{GraftAdoption, GraftRejection, GraftStrength};
1821 crate::run::GraftRecord {
1822 pre: "aaaaaaaaaaaaaaaa".to_owned(),
1823 post: Some("bbbbbbbbbbbbbbbb".to_owned()),
1824 lines: Some(25),
1825 limit: Some(227),
1826 agent: Some("astra".to_owned()),
1827 outcome,
1828 requested: vec![
1829 GraftStrength {
1830 id: "A1".into(),
1831 source: 'A',
1832 text: "Build Persona directly in the test".into(),
1833 },
1834 GraftStrength {
1835 id: "A2".into(),
1836 source: 'A',
1837 text: "Cache the lookup".into(),
1838 },
1839 GraftStrength {
1840 id: "A3".into(),
1841 source: 'A',
1842 text: "Rename the helper".into(),
1843 },
1844 ],
1845 adoption: Some(GraftAdoption {
1846 addressed: vec!["A1".into()],
1847 rejected: vec![GraftRejection {
1848 id: "A2".into(),
1849 why: "too large".into(),
1850 }],
1851 notes: "ported <script>alert(1)</script>".into(),
1852 reasoning: "thinking".into(),
1853 }),
1854 adoption_error: None,
1855 }
1856 }
1857
1858 #[test]
1859 fn an_applied_graft_separates_requested_from_confirmed() {
1860 let mut s = state();
1861 s.graft = Some(graft_record(crate::run::GraftOutcome::Applied));
1862 s.handovers.push(crate::run::Handover {
1863 at: jiff::Timestamp::now(),
1864 node: "graft".into(),
1865 seat: "graft".into(),
1866 from: "sonnet".into(),
1867 to: "astra".into(),
1868 reason: "timed out".into(),
1869 });
1870 let g = &graft_json(&s, None)["graft"];
1871 assert_eq!(g["outcome"], "applied");
1872 assert_eq!(g["agent"], "astra");
1873 assert_eq!(g["handovers"][0]["from"], "sonnet");
1874 assert_eq!(g["lines"], 25);
1875 assert!(g.get("limit").is_none());
1876 let st: Vec<_> = g["requested"]
1877 .as_array()
1878 .expect("a")
1879 .iter()
1880 .map(|r| r["state"].as_str().expect("s").to_owned())
1881 .collect();
1882 assert_eq!(st, ["adopted", "rejected", "unreported"]);
1883 assert_eq!(g["requested"][1]["why"], "too large");
1884 assert_eq!(g["adopted_count"], 1);
1885 assert_eq!(g["unconfirmed_count"], 1);
1886 assert_eq!(g["diff_available"], true);
1887 }
1888
1889 #[test]
1890 fn historical_graft_limits_are_readable_but_not_displayed() {
1891 let mut s = state();
1892 let old = serde_json::to_value(graft_record(crate::run::GraftOutcome::Applied)).unwrap();
1893 assert_eq!(old["limit"], 227);
1894 let mut record: crate::run::GraftRecord = serde_json::from_value(old).unwrap();
1895 assert_eq!(record.limit, Some(227));
1896 record.lines = None;
1897 s.graft = Some(record);
1898 let view = graft_json(&s, None);
1899 assert!(view["graft"]["lines"].is_null());
1900 assert!(view["graft"].get("limit").is_none());
1901 let text = crate::report::run(&s);
1902 assert!(text.contains("changed lines unavailable"));
1903 assert!(!text.contains("limit 227"));
1904 assert!(!text.contains("0 changed lines"));
1905
1906 s.graft.as_mut().unwrap().lines = Some(25);
1907 let text = crate::report::run(&s);
1908 assert!(text.contains("25 changed lines"));
1909 assert!(!text.contains("limit 227"));
1910 }
1911
1912 #[test]
1913 fn a_rolled_back_graft_carries_its_reason_and_no_diff() {
1914 let mut s = state();
1915 let mut g = graft_record(crate::run::GraftOutcome::RolledBack("too big".into()));
1916 g.post = None;
1917 s.graft = Some(g);
1918 let v = graft_json(&s, None);
1919 assert_eq!(v["graft"]["outcome"], "rolled_back");
1920 assert_eq!(v["graft"]["reason"], "too big");
1921 assert_eq!(v["graft"]["diff_available"], false);
1922 assert_eq!(v["tone"], "warn");
1923 }
1924
1925 #[test]
1926 fn a_legacy_record_says_unavailable_and_claims_nothing() {
1927 let mut s = state();
1928 let mut g = graft_record(crate::run::GraftOutcome::Applied);
1929 g.requested.clear();
1930 g.adoption = None;
1931 s.graft = Some(g);
1932 let v = graft_json(&s, None);
1933 let reasons: Vec<_> = v["graft"]["unavailable"]
1934 .as_array()
1935 .expect("a")
1936 .iter()
1937 .map(|u| u["reason"].as_str().expect("s"))
1938 .collect();
1939 assert_eq!(reasons, ["legacy_record", "legacy_record"]);
1940 assert_eq!(v["graft"]["requested"].as_array().expect("a").len(), 0);
1941 assert_eq!(v["graft"]["adopted_count"], 0);
1942 }
1943
1944 #[test]
1945 fn recovered_details_fill_a_legacy_record_and_are_marked() {
1946 let mut s = state();
1947 let mut g = graft_record(crate::run::GraftOutcome::Applied);
1948 let requested = std::mem::take(&mut g.requested);
1949 let adoption = g.adoption.take();
1950 s.graft = Some(g);
1951 let rec = crate::graft_report::Recovered {
1952 requested,
1953 adoption,
1954 unavailable: Vec::new(),
1955 };
1956 let g = &graft_json(&s, Some(&rec))["graft"];
1957 assert_eq!(g["recovered"], true);
1958 assert_eq!(g["requested"][0]["state"], "adopted");
1959 }
1960
1961 #[test]
1962 fn agent_text_stays_text_in_the_view() {
1963 let mut s = state();
1964 let mut g = graft_record(crate::run::GraftOutcome::Applied);
1965 g.requested[0].text = "<img src=x onerror=alert(1)>".into();
1966 s.graft = Some(g);
1967 let v = graft_json(&s, None);
1968 assert_eq!(
1969 v["graft"]["requested"][0]["text"],
1970 "<img src=x onerror=alert(1)>"
1971 );
1972 let notes = v["graft"]["notes_md"].to_string();
1974 assert!(!notes.contains("\"html\""), "{notes}");
1975 }
1976}