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