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 limit: Option<usize>,
954 pub requested: Vec<GraftStrengthView>,
956 pub adopted_count: usize,
958 pub rejected_count: usize,
960 pub unconfirmed_count: usize,
962 pub notes_md: Vec<crate::md::Node>,
964 pub reasoning_md: Vec<crate::md::Node>,
966 pub recovered: bool,
968 pub unavailable: Vec<crate::graft_report::Unavailable>,
970 pub diff_available: bool,
972 pub pre: String,
974 pub post: Option<String>,
976}
977
978fn graft_view(
979 state: &RunState,
980 g: &crate::run::GraftRecord,
981 rec: Option<&crate::graft_report::Recovered>,
982) -> GraftView {
983 use crate::graft_report::{Standing, Unavailable, standing};
984 use crate::run::GraftOutcome;
985 let mut unavailable: Vec<Unavailable> = Vec::new();
986 let mut recovered = false;
987 let requested_src: Vec<_> = if g.requested.is_empty() {
988 match rec {
989 Some(r) => {
990 recovered |= !r.requested.is_empty();
991 r.requested.clone()
992 }
993 None => Vec::new(),
994 }
995 } else {
996 g.requested.clone()
997 };
998 let adoption = match (&g.adoption, rec) {
999 (Some(a), _) => Some(a.clone()),
1000 (None, Some(r)) if g.adoption_error.is_none() => {
1001 recovered |= r.adoption.is_some();
1002 r.adoption.clone()
1003 }
1004 _ => None,
1005 };
1006 if let Some(r) = rec {
1007 unavailable.extend(r.unavailable.iter().cloned());
1008 }
1009 if let Some(e) = &g.adoption_error {
1010 unavailable.push(Unavailable {
1011 what: "adoption",
1012 reason: e.clone(),
1013 });
1014 }
1015 if g.requested.is_empty() && requested_src.is_empty() && rec.is_none() {
1016 unavailable.push(Unavailable {
1017 what: "requested",
1018 reason: "legacy_record".to_owned(),
1019 });
1020 }
1021 if adoption.is_none() && g.adoption_error.is_none() && rec.is_none() {
1022 unavailable.push(Unavailable {
1023 what: "adoption",
1024 reason: "legacy_record".to_owned(),
1025 });
1026 }
1027 let requested: Vec<GraftStrengthView> = requested_src
1028 .iter()
1029 .map(|s| {
1030 let (st, why) = match adoption.as_ref().map(|a| standing(s, a)) {
1031 None => ("unknown", None),
1032 Some(Standing::Adopted) => ("adopted", None),
1033 Some(Standing::Rejected(w)) => ("rejected", Some(w).filter(|w| !w.is_empty())),
1034 Some(Standing::Unreported) => ("unreported", None),
1035 };
1036 GraftStrengthView {
1037 id: s.id.clone(),
1038 source: s.source.to_string(),
1039 text: s.text.clone(),
1040 state: st,
1041 why,
1042 }
1043 })
1044 .collect();
1045 let count = |st: &str| requested.iter().filter(|r| r.state == st).count();
1046 let (adopted_count, rejected_count) = (count("adopted"), count("rejected"));
1047 let mut sources: Vec<String> = requested.iter().map(|r| r.source.clone()).collect();
1048 sources.dedup();
1049 let (outcome, reason) = match &g.outcome {
1050 GraftOutcome::Applied => ("applied", None),
1051 GraftOutcome::WaitingForRestore => ("waiting_for_restore", None),
1052 GraftOutcome::Running => ("unfinished", None),
1053 GraftOutcome::RolledBack(w) => ("rolled_back", Some(w.clone())),
1054 };
1055 let md = |t: &str| {
1056 if t.trim().is_empty() {
1057 Vec::new()
1058 } else {
1059 crate::md::to_nodes(t, &crate::md::ImageBase::None)
1060 }
1061 };
1062 let short = |c: &str| c.chars().take(12).collect::<String>();
1063 if outcome == "applied" && g.post.is_none() {
1064 unavailable.push(Unavailable {
1065 what: "diff",
1066 reason: "no_post_commit".to_owned(),
1067 });
1068 }
1069 GraftView {
1070 outcome,
1071 reason,
1072 agent: g.agent.clone(),
1073 handovers: state
1074 .handovers
1075 .iter()
1076 .filter(|h| h.node == "graft")
1077 .map(|h| GraftHandoverView {
1078 from: h.from.clone(),
1079 to: h.to.clone(),
1080 reason: h.reason.clone(),
1081 })
1082 .collect(),
1083 winner: state.tally.as_ref().map(|t| t.winner.to_string()),
1084 sources,
1085 lines: g.lines,
1086 limit: g.limit,
1087 unconfirmed_count: requested.len() - adopted_count - rejected_count,
1088 adopted_count,
1089 rejected_count,
1090 requested,
1091 notes_md: adoption.as_ref().map(|a| md(&a.notes)).unwrap_or_default(),
1092 reasoning_md: adoption
1093 .as_ref()
1094 .map(|a| md(&a.reasoning))
1095 .unwrap_or_default(),
1096 recovered,
1097 unavailable,
1098 diff_available: g.post.is_some(),
1099 pre: short(&g.pre),
1100 post: g.post.as_deref().map(short),
1101 }
1102}
1103
1104pub fn build(state: &RunState, live: Liveness) -> RunReportView {
1107 build_with(state, live, None)
1108}
1109
1110pub fn build_with(
1113 state: &RunState,
1114 live: Liveness,
1115 graft: Option<&crate::graft_report::Recovered>,
1116) -> RunReportView {
1117 let mut sections: Vec<Section> = Vec::new();
1118 let met_quorum = state.tally.as_ref().is_none_or(|t| t.met_quorum);
1119
1120 sections.push(Section::Candidates {
1121 head: head(
1122 "candidates",
1123 if state.candidates.iter().any(|c| c.failed.is_some()) {
1124 Tone::Warn
1125 } else {
1126 Tone::Neutral
1127 },
1128 true,
1129 ),
1130 items: state
1131 .candidates
1132 .iter()
1133 .map(|c| {
1134 let is_winner = state.tally.as_ref().is_some_and(|t| t.winner == c.label);
1135 let (st, detail) = match (&c.failed, c.empty, &c.verified_noop) {
1136 (Some(e), _, _) => ("failed", Some(e.clone())),
1137 (None, true, Some(ev)) => ("verified_noop", Some(first_line(ev))),
1138 (None, true, None) => ("no_change", None),
1139 _ => (
1140 "changed",
1141 Some(first_line(&c.summary)).filter(|s| !s.is_empty()),
1142 ),
1143 };
1144 CandidateRow {
1145 label: c.label.to_string(),
1146 agent: c.agent.clone(),
1147 state: st,
1148 detail,
1149 files: c.files,
1150 commits: c.commits,
1151 duration_secs: c.duration_ms / 1000,
1152 winner: is_winner && met_quorum,
1153 provisional_winner: is_winner && !met_quorum,
1154 }
1155 })
1156 .collect(),
1157 });
1158
1159 if !state.judgements.is_empty() {
1160 sections.push(Section::Judging {
1161 head: head("blind judging", Tone::Neutral, false),
1162 judges: state
1163 .judgements
1164 .iter()
1165 .map(|j| JudgeRow {
1166 judge: j.judge,
1167 agent: j.agent.clone(),
1168 ranking: j.ranking.iter().collect(),
1169 confidence: j.confidence,
1170 failed: j.failed.clone(),
1171 })
1172 .collect(),
1173 deliberation: state
1174 .tally
1175 .as_ref()
1176 .filter(|t| t.deliberated)
1177 .map(|_| {
1178 state
1179 .deliberation
1180 .iter()
1181 .flat_map(|round| {
1182 round.turns.iter().map(move |turn| {
1183 format!(
1184 "r{} judge {} -> {}",
1185 round.round,
1186 turn.judge,
1187 turn.tentative.map_or("-".to_owned(), |c| c.to_string())
1188 )
1189 })
1190 })
1191 .collect()
1192 })
1193 .unwrap_or_default(),
1194 rounds: state
1195 .deliberation
1196 .iter()
1197 .map(|r| DeliberationRoundView {
1198 round: r.round,
1199 turns: r
1200 .turns
1201 .iter()
1202 .map(|t| DeliberationTurnView {
1203 judge: t.judge,
1204 agent: t.agent.clone(),
1205 tentative: t.tentative.map(|c| c.to_string()),
1206 body: t.body.clone(),
1207 body_md: crate::md::to_nodes(&t.body, &crate::md::ImageBase::None),
1208 })
1209 .collect(),
1210 })
1211 .collect(),
1212 votes: state
1213 .votes
1214 .iter()
1215 .map(|v| VoteRow {
1216 judge: v.judge,
1217 agent: v.agent.clone(),
1218 vote: v.vote.unwrap_or('?').to_string(),
1219 changed: v.changed,
1220 })
1221 .collect(),
1222 });
1223 }
1224
1225 if let Some(t) = &state.tally {
1226 let initial = match (t.rankings, t.unanimous_initial) {
1227 (0, _) => "none",
1228 (1, _) => "one",
1229 (_, true) => "unanimous",
1230 (_, false) => "split",
1231 };
1232 sections.push(Section::Tally {
1233 head: head(
1234 "tally",
1235 if t.met_quorum { Tone::Ok } else { Tone::Fail },
1236 true,
1237 ),
1238 tally: TallyView {
1239 met_quorum: t.met_quorum,
1240 decided: t.met_quorum,
1241 provisional: !t.met_quorum,
1242 winner: t.winner.to_string(),
1243 judges: t.judges,
1244 present: t.present,
1245 quorum: t.quorum,
1246 uncontested: t.uncontested.clone(),
1247 first_choice: t
1248 .first_choice
1249 .iter()
1250 .map(|(k, v)| (k.to_string(), *v))
1251 .collect(),
1252 initial,
1253 unanimous_final: t.unanimous_final,
1254 changed_votes: t.changed_votes,
1255 tie_break: t.tie_break.clone(),
1256 rate_limited: state.quota.iter().map(|q| q.seat.clone()).collect(),
1257 },
1258 });
1259 }
1260
1261 let notes = |key: &'static str, title: &str, tone: Tone, lines: Vec<String>| {
1262 (!lines.is_empty()).then(|| Section::Notes {
1263 head: head(title, tone, true),
1264 key,
1265 lines,
1266 })
1267 };
1268 sections.extend(notes(
1269 "handovers",
1270 "seats handed over",
1271 Tone::Warn,
1272 state
1273 .handovers
1274 .iter()
1275 .map(|h| {
1276 format!(
1277 "{} {} -> {} ({}: {})",
1278 h.seat, h.from, h.to, h.node, h.reason
1279 )
1280 })
1281 .collect(),
1282 ));
1283 sections.extend(notes(
1284 "withheld",
1285 "withheld from commit",
1286 Tone::Warn,
1287 state
1288 .withheld
1289 .iter()
1290 .map(|w| {
1291 format!(
1292 "{} ({} lockfile; the directory uses {})",
1293 w.path, w.manager, w.kept_by
1294 )
1295 })
1296 .collect(),
1297 ));
1298
1299 if let Some(g) = &state.graft {
1300 let view = graft_view(state, g, graft);
1301 let tone = match view.outcome {
1302 "applied" if view.unconfirmed_count == 0 && !view.requested.is_empty() => Tone::Ok,
1303 "applied" => Tone::Neutral,
1304 _ => Tone::Warn,
1305 };
1306 sections.push(Section::Graft {
1307 head: head("graft", tone, true),
1308 graft: view,
1309 });
1310 }
1311
1312 if !state.reviews.is_empty() {
1313 let n = state.reviews.len();
1314 let rounds: Vec<RoundView> = state
1315 .reviews
1316 .iter()
1317 .enumerate()
1318 .map(|(i, r)| round_view(r, i + 1 == n))
1319 .collect();
1320 let handed_off = state.handed_off_with_open_findings().then(|| {
1321 format!(
1322 "handed off with {} finding(s) still open - gate and e2e were green; see above \
1323 for what a person should still look at",
1324 state.open_findings().len()
1325 )
1326 });
1327 let tone = if handed_off.is_some() {
1328 Tone::Warn
1329 } else {
1330 rounds.last().map_or(Tone::Neutral, |r| r.tone)
1331 };
1332 sections.push(Section::Review {
1333 head: head("review + verification", tone, true),
1334 rounds,
1335 handed_off,
1336 });
1337 }
1338
1339 if !state.operator_fixes.is_empty() {
1340 sections.push(Section::OperatorFixes {
1341 head: head("operator fix(es)", Tone::Neutral, true),
1342 requests: state
1343 .operator_fixes
1344 .iter()
1345 .map(|req| OperatorFixView {
1346 requested_at: req.requested_at.to_string(),
1347 stale: req.stale,
1348 reason: req.reason.clone(),
1349 findings: req
1350 .findings
1351 .iter()
1352 .map(|f| {
1353 let (outcome, why) = match &f.outcome {
1354 OperatorFixOutcome::Pending => ("pending", None),
1355 OperatorFixOutcome::Addressed => ("addressed", None),
1356 OperatorFixOutcome::Rejected { why } => {
1357 ("rejected", Some(why.clone()))
1358 }
1359 OperatorFixOutcome::Unreported => ("unreported", None),
1360 };
1361 OperatorFindingRow {
1362 id: f.id.clone(),
1363 severity: severity_name(f.severity),
1364 title: f.title.clone(),
1365 outcome,
1366 why,
1367 }
1368 })
1369 .collect(),
1370 follow_up_review_run: req.follow_up_review_run.clone(),
1371 unverified_commit: req.follow_up_review_run.is_none()
1372 && req.fix.as_ref().is_some_and(|fx| fx.committed),
1373 })
1374 .collect(),
1375 });
1376 }
1377
1378 if let Some(bs) = &state.base_sync {
1379 let (st, tone) = if bs.already_in.is_some() {
1380 ("already_in", Tone::Neutral)
1381 } else if bs.conflict.is_some() {
1382 ("conflict", Tone::Fail)
1383 } else if bs.behind == 0 {
1384 ("in_sync", Tone::Ok)
1385 } else {
1386 ("behind", Tone::Warn)
1387 };
1388 sections.push(Section::BaseSync {
1389 head: head("base sync", tone, true),
1390 base_sync: BaseSyncView {
1391 base_branch: state.base_branch.clone(),
1392 tip: short(&bs.tip),
1393 state: st,
1394 behind: bs.behind,
1395 attempts: bs.attempts,
1396 conflict: bs.conflict.as_deref().map(first_line),
1397 already_in: bs
1398 .already_in
1399 .as_ref()
1400 .map(|a| format!("{} ({} match)", a.names(), a.proof.as_str())),
1401 },
1402 });
1403 }
1404
1405 if !state.pre_gate.is_empty() {
1406 let all_ok = state.pre_gate.iter().all(CommandOutcome::ok);
1407 sections.push(Section::PreGate {
1408 head: head(
1409 "pre_gate",
1410 if all_ok { Tone::Ok } else { Tone::Warn },
1411 !all_ok,
1412 ),
1413 commands: state.pre_gate.iter().map(|o| command(o, true)).collect(),
1414 committed: state.pre_gate_commit.as_deref().map(short),
1415 });
1416 }
1417
1418 match state.gate_status() {
1419 GateStatus::NotRun => {}
1420 gs => {
1421 let st = match gs {
1422 GateStatus::NotConfigured => "not_configured",
1423 GateStatus::Passed => "passed",
1424 _ => "failed",
1425 };
1426 sections.push(Section::Gate {
1427 head: head(
1428 "gate",
1429 if gs == GateStatus::NotConfigured {
1430 Tone::Neutral
1431 } else if gs.ok() {
1432 Tone::Ok
1433 } else {
1434 Tone::Fail
1435 },
1436 true,
1437 ),
1438 status: st,
1439 commands: state.gate.iter().map(|o| command(o, false)).collect(),
1440 });
1441 }
1442 }
1443
1444 if let Some(m) = &state.merge {
1445 let by_design = m.mode == MergeMode::None;
1446 let (st, tone) = if by_design {
1447 ("not_landed_by_design", Tone::Neutral)
1448 } else if m.empty {
1449 ("empty", Tone::Warn)
1450 } else if m.ok && state.status == crate::run::RunStatus::Merged {
1451 ("ok", Tone::Ok)
1452 } else if m.ok && m.mode == MergeMode::Pr {
1453 ("published", Tone::Neutral)
1454 } else {
1455 ("not_merged", Tone::Warn)
1456 };
1457 sections.push(Section::Merge {
1458 head: head("merge", tone, true),
1459 merge: MergeView {
1460 mode: format!("{:?}", m.mode).to_lowercase(),
1461 state: st,
1462 detail: m.detail.lines().next().unwrap_or("").to_owned(),
1463 branch: by_design
1464 .then(|| state.winner().map(|w| w.branch.clone()))
1465 .flatten(),
1466 squash_caveat: by_design && state.config.merge.style == MergeStyle::Squash,
1467 red_at_merge: state
1468 .pr
1469 .as_ref()
1470 .map(|pr| pr.red_at_merge.clone())
1471 .unwrap_or_default(),
1472 },
1473 });
1474 }
1475
1476 if !state.followups.is_empty() {
1477 sections.push(Section::Followups {
1478 head: head("follow-ups", Tone::Neutral, true),
1479 items: state
1480 .followups
1481 .iter()
1482 .map(|f| FollowupRow {
1483 task: crate::queue::short(&f.task).to_owned(),
1484 findings: f.findings.clone(),
1485 })
1486 .collect(),
1487 });
1488 }
1489
1490 if let Some(b) = &state.release_bump {
1491 let automerge = if b.automerge_enabled {
1492 "enabled"
1493 } else if b.merged_directly {
1494 "merged_directly"
1495 } else if b.local {
1496 "local"
1497 } else if b.problem.is_some() {
1498 "failed"
1499 } else {
1500 "not_enabled"
1501 };
1502 sections.push(Section::ReleaseBump {
1503 head: head(
1504 "release bump",
1505 if automerge == "failed" || b.action_required.is_some() {
1506 Tone::Warn
1507 } else {
1508 Tone::Neutral
1509 },
1510 true,
1511 ),
1512 bump: ReleaseBumpView {
1513 version: b.version.clone(),
1514 pr_url: b.pr_url.clone(),
1515 automerge,
1516 problem: b.problem.as_deref().map(first_line),
1517 action_required: b.action_required.clone(),
1518 },
1519 });
1520 }
1521
1522 sections.extend(notes(
1523 "leaks",
1524 "blindness warnings",
1525 Tone::Warn,
1526 state
1527 .leaks
1528 .iter()
1529 .map(|l| format!("{} x{} in {}", l.token, l.count, l.site))
1530 .collect(),
1531 ));
1532
1533 if let Some(w) = state.winner()
1534 && !w.folded
1535 {
1536 sections.push(Section::WinnerWorktree {
1537 head: head("winner worktree", Tone::Neutral, true),
1538 worktree: w.worktree.display().to_string(),
1539 branch: w.branch.clone(),
1540 provisional: !met_quorum,
1541 });
1542 }
1543
1544 sections.extend(jobs_section(state));
1545
1546 RunReportView {
1547 schema: SCHEMA,
1548 header: Header {
1549 id: state.id.clone(),
1550 status: state.status.display_label().to_owned(),
1551 tone: status_tone(state),
1552 unmerged_by_design: state.unmerged_by_design(),
1553 needs_attention: state.needs_attention(),
1554 repo: state.repo.display().to_string(),
1555 base_branch: state.base_branch.clone(),
1556 base_commit: short(&state.base_commit),
1557 created: state.created_local(),
1558 task: first_line(&state.instruction),
1559 origin: crate::run::origin_label(state.origin.as_ref()),
1560 state_dir: state.dir().display().to_string(),
1561 },
1562 sections,
1563 active_seats: crate::report::active_seats(state, live),
1564 }
1565}
1566
1567#[cfg(test)]
1568mod tests {
1569 use super::*;
1570 use crate::config::Config;
1571 use crate::run::{CommandOutcome, FixRecord, ReviewRecord, Tally};
1572 use crate::verdict::Rejection;
1573 use std::path::PathBuf;
1574
1575 fn state() -> RunState {
1576 crate::run::pin_test_home();
1578 RunState::new(
1579 PathBuf::from("/repo/magi"),
1580 "main".to_owned(),
1581 "0123456789abcdef".to_owned(),
1582 "Add a thing\n\nlong".to_owned(),
1583 Config::default(),
1584 )
1585 }
1586
1587 fn finding(id: &str, severity: Severity) -> Finding {
1588 Finding {
1589 id: id.to_owned(),
1590 severity,
1591 file: Some("src/a.rs".to_owned()),
1592 line: Some(7),
1593 title: format!("title {id}"),
1594 detail: String::new(),
1595 }
1596 }
1597
1598 fn cmd(code: Option<i32>, blocked: bool) -> CommandOutcome {
1599 CommandOutcome {
1600 command: "cargo test".to_owned(),
1601 code,
1602 output_tail: "boom\n".repeat(1_000),
1603 duration_ms: 5,
1604 resource_blocked: blocked,
1605 }
1606 }
1607
1608 fn round(n: usize) -> ReviewRound {
1609 let mut r: ReviewRound = serde_json::from_value(serde_json::json!({
1610 "round": n, "head": "abcdef0123", "reviews": []
1611 }))
1612 .expect("round");
1613 r.answered = 1;
1614 r.expected = 1;
1615 r.reviews.push(
1616 serde_json::from_value::<ReviewRecord>(serde_json::json!({
1617 "reviewer": 1, "agent": "a"
1618 }))
1619 .expect("record"),
1620 );
1621 r
1622 }
1623
1624 fn tally(met: bool) -> Tally {
1625 serde_json::from_value(serde_json::json!({
1626 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
1627 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
1628 "unanimous_final": true, "judges": 3, "present": if met { 3 } else { 1 },
1629 "quorum": 2, "met_quorum": met, "rankings": 1
1630 }))
1631 .expect("tally")
1632 }
1633
1634 fn kinds(v: &RunReportView) -> Vec<String> {
1635 serde_json::to_value(&v.sections)
1636 .expect("json")
1637 .as_array()
1638 .expect("array")
1639 .iter()
1640 .map(|s| s["kind"].as_str().expect("kind").to_owned())
1641 .collect()
1642 }
1643
1644 #[test]
1645 fn sections_follow_the_text_report_order() {
1646 let mut s = state();
1647 s.tally = Some(tally(true));
1648 s.reviews.push(round(1));
1649 s.base_sync = Some(
1650 serde_json::from_value(serde_json::json!({"tip": "abc", "behind": 0, "attempts": 0}))
1651 .expect("sync"),
1652 );
1653 s.pre_gate.push(cmd(Some(0), false));
1654 s.jobs.push(
1655 serde_json::from_value(serde_json::json!({
1656 "node": "fix", "seat": "fix", "id": "j1", "description": "ls",
1657 "checked_at": "2026-01-01T00:00:00Z", "status": "Completed",
1658 "exit_code": 0, "source": "codex"
1659 }))
1660 .expect("job"),
1661 );
1662 let v = build(&s, Liveness::Unknown);
1663 assert_eq!(
1664 kinds(&v),
1665 [
1666 "candidates",
1667 "tally",
1668 "review",
1669 "base_sync",
1670 "pre_gate",
1671 "jobs"
1672 ]
1673 );
1674 let json = serde_json::to_value(&v).expect("json");
1675 assert_eq!(json["schema"], SCHEMA);
1676 let jobs = json["sections"].as_array().expect("a").last().expect("l");
1677 assert_eq!(jobs["default_open"], false, "jobs start collapsed");
1678 assert_eq!(jobs["groups"][0]["jobs"][0]["status"], "completed");
1679 }
1680
1681 #[test]
1682 fn a_stalled_tally_is_never_decided() {
1683 let mut s = state();
1684 s.tally = Some(tally(false));
1685 let json = serde_json::to_value(build(&s, Liveness::Unknown)).expect("json");
1686 let t = json["sections"]
1687 .as_array()
1688 .expect("a")
1689 .iter()
1690 .find(|x| x["kind"] == "tally")
1691 .expect("tally");
1692 assert_eq!(t["tally"]["met_quorum"], false);
1693 assert_eq!(t["tally"]["decided"], false);
1694 assert_eq!(t["tally"]["provisional"], true);
1695 assert_eq!(t["tone"], "fail");
1696 }
1697
1698 #[test]
1699 fn findings_keep_severity_blocking_and_what_the_fixer_said() {
1700 let mut s = state();
1701 let mut r = round(1);
1702 r.reviews[0].findings = vec![
1703 finding("R1-1-1", Severity::Blocker),
1704 finding("R1-1-2", Severity::Major),
1705 finding("R1-1-3", Severity::Minor),
1706 finding("R1-1-4", Severity::Nit),
1707 ];
1708 r.fix = Some(
1709 serde_json::from_value::<FixRecord>(serde_json::json!({"agent": "f"})).expect("fix"),
1710 );
1711 let fix = r.fix.as_mut().expect("fix");
1712 fix.addressed = vec!["R1-1-1".to_owned()];
1713 fix.rejected = vec![
1714 Rejection {
1715 id: "R1-1-2".to_owned(),
1716 why: "not a bug".to_owned(),
1717 },
1718 Rejection {
1719 id: "R9-9-9".to_owned(),
1720 why: "unknown id".to_owned(),
1721 },
1722 ];
1723 s.reviews.push(r);
1724 let json = serde_json::to_value(build(&s, Liveness::Unknown)).expect("json");
1725 let review = json["sections"]
1726 .as_array()
1727 .expect("a")
1728 .iter()
1729 .find(|x| x["kind"] == "review")
1730 .expect("review");
1731 let f = &review["rounds"][0]["findings"];
1732 let at = |i: usize| {
1733 (
1734 f[i]["severity"].clone(),
1735 f[i]["blocking"].clone(),
1736 f[i]["state"].clone(),
1737 )
1738 };
1739 assert_eq!(at(0), ("blocker".into(), true.into(), "fixed".into()));
1740 assert_eq!(at(1), ("major".into(), true.into(), "declined".into()));
1741 assert_eq!(f[1]["declined_why"], "not a bug");
1742 assert_eq!(at(2), ("minor".into(), false.into(), "open".into()));
1743 assert_eq!(at(3), ("nit".into(), false.into(), "open".into()));
1744 assert_eq!(
1745 f[4]["id"], "R9-9-9",
1746 "an unmatched declined id is not dropped"
1747 );
1748 assert_eq!(f[4]["state"], "declined");
1749 }
1750
1751 #[test]
1752 fn a_deferred_e2e_is_not_a_failed_one() {
1753 let mut s = state();
1754 let mut deferred = round(1);
1755 deferred.e2e_deferred = true;
1756 deferred.e2e_defer_reason = Some("blocking findings".to_owned());
1757 let mut failed = round(2);
1758 failed.e2e = vec![cmd(Some(1), false)];
1759 let mut blocked = round(3);
1760 blocked.e2e = vec![cmd(None, true)];
1761 let none = round(4);
1762 s.reviews = vec![deferred, failed, blocked, none];
1763 let json = serde_json::to_value(build(&s, Liveness::Unknown)).expect("json");
1764 let rounds = &json["sections"]
1765 .as_array()
1766 .expect("a")
1767 .iter()
1768 .find(|x| x["kind"] == "review")
1769 .expect("review")["rounds"];
1770 let st = |i: usize| {
1771 rounds[i]["e2e"]["state"]
1772 .as_str()
1773 .expect("state")
1774 .to_owned()
1775 };
1776 assert_eq!(
1777 [st(0), st(1), st(2), st(3)],
1778 ["deferred", "fail", "blocked", "not_configured"]
1779 );
1780 assert_eq!(rounds[0]["e2e"]["defer_reason"], "blocking findings");
1781 let tail = rounds[1]["commands"][0]["output_tail"]
1782 .as_str()
1783 .expect("tail");
1784 assert!(tail.len() < 2_100, "output is bounded: {}", tail.len());
1785 }
1786 fn graft_json(s: &RunState, rec: Option<&crate::graft_report::Recovered>) -> serde_json::Value {
1787 let json = serde_json::to_value(build_with(s, Liveness::Unknown, rec)).expect("json");
1788 json["sections"]
1789 .as_array()
1790 .expect("a")
1791 .iter()
1792 .find(|x| x["kind"] == "graft")
1793 .cloned()
1794 .unwrap_or(serde_json::Value::Null)
1795 }
1796
1797 fn graft_record(outcome: crate::run::GraftOutcome) -> crate::run::GraftRecord {
1798 use crate::run::{GraftAdoption, GraftRejection, GraftStrength};
1799 crate::run::GraftRecord {
1800 pre: "aaaaaaaaaaaaaaaa".to_owned(),
1801 post: Some("bbbbbbbbbbbbbbbb".to_owned()),
1802 lines: Some(25),
1803 limit: Some(227),
1804 agent: Some("astra".to_owned()),
1805 outcome,
1806 requested: vec![
1807 GraftStrength {
1808 id: "A1".into(),
1809 source: 'A',
1810 text: "Build Persona directly in the test".into(),
1811 },
1812 GraftStrength {
1813 id: "A2".into(),
1814 source: 'A',
1815 text: "Cache the lookup".into(),
1816 },
1817 GraftStrength {
1818 id: "A3".into(),
1819 source: 'A',
1820 text: "Rename the helper".into(),
1821 },
1822 ],
1823 adoption: Some(GraftAdoption {
1824 addressed: vec!["A1".into()],
1825 rejected: vec![GraftRejection {
1826 id: "A2".into(),
1827 why: "too large".into(),
1828 }],
1829 notes: "ported <script>alert(1)</script>".into(),
1830 reasoning: "thinking".into(),
1831 }),
1832 adoption_error: None,
1833 }
1834 }
1835
1836 #[test]
1837 fn an_applied_graft_separates_requested_from_confirmed() {
1838 let mut s = state();
1839 s.graft = Some(graft_record(crate::run::GraftOutcome::Applied));
1840 s.handovers.push(crate::run::Handover {
1841 at: jiff::Timestamp::now(),
1842 node: "graft".into(),
1843 seat: "graft".into(),
1844 from: "sonnet".into(),
1845 to: "astra".into(),
1846 reason: "timed out".into(),
1847 });
1848 let g = &graft_json(&s, None)["graft"];
1849 assert_eq!(g["outcome"], "applied");
1850 assert_eq!(g["agent"], "astra");
1851 assert_eq!(g["handovers"][0]["from"], "sonnet");
1852 assert_eq!(g["lines"], 25);
1853 assert_eq!(g["limit"], 227);
1854 let st: Vec<_> = g["requested"]
1855 .as_array()
1856 .expect("a")
1857 .iter()
1858 .map(|r| r["state"].as_str().expect("s").to_owned())
1859 .collect();
1860 assert_eq!(st, ["adopted", "rejected", "unreported"]);
1861 assert_eq!(g["requested"][1]["why"], "too large");
1862 assert_eq!(g["adopted_count"], 1);
1863 assert_eq!(g["unconfirmed_count"], 1);
1864 assert_eq!(g["diff_available"], true);
1865 }
1866
1867 #[test]
1868 fn a_rolled_back_graft_carries_its_reason_and_no_diff() {
1869 let mut s = state();
1870 let mut g = graft_record(crate::run::GraftOutcome::RolledBack("too big".into()));
1871 g.post = None;
1872 s.graft = Some(g);
1873 let v = graft_json(&s, None);
1874 assert_eq!(v["graft"]["outcome"], "rolled_back");
1875 assert_eq!(v["graft"]["reason"], "too big");
1876 assert_eq!(v["graft"]["diff_available"], false);
1877 assert_eq!(v["tone"], "warn");
1878 }
1879
1880 #[test]
1881 fn a_legacy_record_says_unavailable_and_claims_nothing() {
1882 let mut s = state();
1883 let mut g = graft_record(crate::run::GraftOutcome::Applied);
1884 g.requested.clear();
1885 g.adoption = None;
1886 s.graft = Some(g);
1887 let v = graft_json(&s, None);
1888 let reasons: Vec<_> = v["graft"]["unavailable"]
1889 .as_array()
1890 .expect("a")
1891 .iter()
1892 .map(|u| u["reason"].as_str().expect("s"))
1893 .collect();
1894 assert_eq!(reasons, ["legacy_record", "legacy_record"]);
1895 assert_eq!(v["graft"]["requested"].as_array().expect("a").len(), 0);
1896 assert_eq!(v["graft"]["adopted_count"], 0);
1897 }
1898
1899 #[test]
1900 fn recovered_details_fill_a_legacy_record_and_are_marked() {
1901 let mut s = state();
1902 let mut g = graft_record(crate::run::GraftOutcome::Applied);
1903 let requested = std::mem::take(&mut g.requested);
1904 let adoption = g.adoption.take();
1905 s.graft = Some(g);
1906 let rec = crate::graft_report::Recovered {
1907 requested,
1908 adoption,
1909 unavailable: Vec::new(),
1910 };
1911 let g = &graft_json(&s, Some(&rec))["graft"];
1912 assert_eq!(g["recovered"], true);
1913 assert_eq!(g["requested"][0]["state"], "adopted");
1914 }
1915
1916 #[test]
1917 fn agent_text_stays_text_in_the_view() {
1918 let mut s = state();
1919 let mut g = graft_record(crate::run::GraftOutcome::Applied);
1920 g.requested[0].text = "<img src=x onerror=alert(1)>".into();
1921 s.graft = Some(g);
1922 let v = graft_json(&s, None);
1923 assert_eq!(
1924 v["graft"]["requested"][0]["text"],
1925 "<img src=x onerror=alert(1)>"
1926 );
1927 let notes = v["graft"]["notes_md"].to_string();
1929 assert!(!notes.contains("\"html\""), "{notes}");
1930 }
1931}