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