Skip to main content

magi/
report_view.rs

1//! A structured view of the run report, for the web UI.
2//!
3//! [`crate::report::run`] renders a run as ANSI text, and that stays the one
4//! text implementation (the TUI and `magi show` read it). This module is the
5//! second reader of the same [`RunState`]: it decides *which sections exist
6//! and in what order* the way `report::run` does, and leaves every judgement
7//! to the accessors the text report already uses (`e2e_status`,
8//! `gate_status`, `Severity::blocks`, `ReviewRound::incomplete`,
9//! `final_votes`, `handed_off_with_open_findings`). Nothing here re-derives a
10//! verdict; when a section is added to the text report, add it here too.
11//!
12//! The one rule a reader of this file must keep: a tally below quorum is
13//! never *decided*. `Tally::decided` is `met_quorum`, and the winner it names
14//! is `provisional` otherwise, so a stalled run cannot render as a settled one.
15use 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
26/// Bumped when a field changes meaning; the client falls back to the raw text
27/// for a schema it does not know rather than render a guess.
28pub const SCHEMA: u32 = 1;
29
30/// How many bytes of a command's output the view carries, the same bound the
31/// text report applies.
32const OUTPUT_TAIL: usize = 2_000;
33
34/// How a section should read at a glance.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
36#[serde(rename_all = "snake_case")]
37pub enum Tone {
38    /// Good, finished.
39    Ok,
40    /// Needs a look, nothing is broken.
41    Warn,
42    /// Broken or untrustworthy.
43    Fail,
44    /// Information only.
45    Neutral,
46}
47
48/// The whole report.
49#[derive(Debug, Serialize)]
50pub struct RunReportView {
51    /// [`SCHEMA`].
52    pub schema: u32,
53    /// What `report::run` prints before the first section.
54    pub header: Header,
55    /// In `report::run` order.
56    pub sections: Vec<Section>,
57    /// `report::active_seats`, which is already text and has no structure to
58    /// add. Empty when no seat is mid-answer.
59    pub active_seats: String,
60}
61
62/// The top block.
63#[derive(Debug, Serialize)]
64pub struct Header {
65    /// Run id.
66    pub id: String,
67    /// `RunStatus::display_label`.
68    pub status: String,
69    /// Status as a tone; a stalled run is never `ok`.
70    pub tone: Tone,
71    /// Landed nothing, and that was the configuration.
72    pub unmerged_by_design: bool,
73    /// A release step needs a person.
74    pub needs_attention: bool,
75    /// Repository path.
76    pub repo: String,
77    /// Base branch.
78    pub base_branch: String,
79    /// Base commit, abbreviated.
80    pub base_commit: String,
81    /// Creation time, local.
82    pub created: String,
83    /// First line of the task.
84    pub task: String,
85    /// Where the task came from.
86    pub origin: String,
87    /// The run's state directory.
88    pub state_dir: String,
89}
90
91/// Fields every section shares.
92#[derive(Debug, Serialize)]
93pub struct Head {
94    /// Heading, as the text report words it.
95    pub title: String,
96    /// At-a-glance state.
97    pub tone: Tone,
98    /// Whether the card starts expanded.
99    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/// One card.
111#[derive(Debug, Serialize)]
112#[serde(tag = "kind", rename_all = "snake_case")]
113pub enum Section {
114    /// Implementations.
115    Candidates {
116        /// Title, tone and default state.
117        #[serde(flatten)]
118        head: Head,
119        /// Rows.
120        items: Vec<CandidateRow>,
121    },
122    /// Blind judging, deliberation and final votes.
123    Judging {
124        /// Title, tone and default state.
125        #[serde(flatten)]
126        head: Head,
127        /// One row per judge.
128        judges: Vec<JudgeRow>,
129        /// One line per deliberation turn.
130        deliberation: Vec<String>,
131        /// Final votes.
132        votes: Vec<VoteRow>,
133    },
134    /// The mechanical count.
135    Tally {
136        /// Title, tone and default state.
137        #[serde(flatten)]
138        head: Head,
139        /// The count.
140        tally: TallyView,
141    },
142    /// Seats handed over, files withheld, blindness warnings: one line each.
143    Notes {
144        /// Title, tone and default state.
145        #[serde(flatten)]
146        head: Head,
147        /// Which kind of note.
148        key: &'static str,
149        /// One line each.
150        lines: Vec<String>,
151    },
152    /// Review + verification, grouped by round.
153    Review {
154        /// Title, tone and default state.
155        #[serde(flatten)]
156        head: Head,
157        /// One entry per round, oldest first.
158        rounds: Vec<RoundView>,
159        /// Set when the loop handed off with findings still open.
160        handed_off: Option<String>,
161    },
162    /// Fixes the operator asked for.
163    OperatorFixes {
164        /// Title, tone and default state.
165        #[serde(flatten)]
166        head: Head,
167        /// One entry per request.
168        requests: Vec<OperatorFixView>,
169    },
170    /// The winner against the base's tip.
171    BaseSync {
172        /// Title, tone and default state.
173        #[serde(flatten)]
174        head: Head,
175        /// The state.
176        base_sync: BaseSyncView,
177    },
178    /// Mechanical pre-gate commands.
179    PreGate {
180        /// Title, tone and default state.
181        #[serde(flatten)]
182        head: Head,
183        /// Per-command results.
184        commands: Vec<CommandView>,
185        /// Mechanical fixes commit, abbreviated.
186        committed: Option<String>,
187    },
188    /// The final gate.
189    Gate {
190        /// Title, tone and default state.
191        #[serde(flatten)]
192        head: Head,
193        /// `passed`, `passed_no_commands` or `failed`.
194        status: &'static str,
195        /// Per-command results.
196        commands: Vec<CommandView>,
197    },
198    /// What happened to the branch.
199    Merge {
200        /// Title, tone and default state.
201        #[serde(flatten)]
202        head: Head,
203        /// The outcome.
204        merge: MergeView,
205    },
206    /// Tasks filed from open findings.
207    Followups {
208        /// Title, tone and default state.
209        #[serde(flatten)]
210        head: Head,
211        /// Rows.
212        items: Vec<FollowupRow>,
213    },
214    /// Release bump after the merge.
215    ReleaseBump {
216        /// Title, tone and default state.
217        #[serde(flatten)]
218        head: Head,
219        /// The bump.
220        bump: ReleaseBumpView,
221    },
222    /// Where the winner lives.
223    WinnerWorktree {
224        /// Title, tone and default state.
225        #[serde(flatten)]
226        head: Head,
227        /// Worktree path.
228        worktree: String,
229        /// Branch.
230        branch: String,
231        /// The tally behind it did not reach quorum.
232        provisional: bool,
233    },
234    /// Commands the seats' own CLIs reported.
235    Jobs {
236        /// Title, tone and default state.
237        #[serde(flatten)]
238        head: Head,
239        /// Per seat.
240        groups: Vec<JobGroup>,
241        /// Why absence is not evidence.
242        coverage: &'static str,
243        /// No evidence yet, though the roster can report it.
244        empty_note: Option<&'static str>,
245    },
246}
247
248/// One implementation.
249#[derive(Debug, Serialize)]
250pub struct CandidateRow {
251    /// Blind label.
252    pub label: String,
253    /// Agent that wrote it.
254    pub agent: String,
255    /// `failed`, `verified_noop`, `no_change` or `changed`.
256    pub state: &'static str,
257    /// The failure, the no-op evidence or the candidate's summary, one line.
258    pub detail: Option<String>,
259    /// Files touched.
260    pub files: usize,
261    /// Commits ahead of base.
262    pub commits: usize,
263    /// Wall-clock seconds.
264    pub duration_secs: u64,
265    /// The tally's winner, and the tally met quorum.
266    pub winner: bool,
267    /// The tally's winner, but the tally did not meet quorum.
268    pub provisional_winner: bool,
269}
270
271/// One judge's ranking.
272#[derive(Debug, Serialize)]
273pub struct JudgeRow {
274    /// Seat number.
275    pub judge: usize,
276    /// Agent.
277    pub agent: String,
278    /// Ranking, best first; empty when none.
279    pub ranking: String,
280    /// Confidence, when given.
281    pub confidence: Option<u8>,
282    /// Why no ranking, when none.
283    pub failed: Option<String>,
284}
285
286/// One judge's final vote.
287#[derive(Debug, Serialize)]
288pub struct VoteRow {
289    /// Seat number.
290    pub judge: usize,
291    /// Agent.
292    pub agent: String,
293    /// The label voted for, `?` when unreadable.
294    pub vote: String,
295    /// Moved after deliberation.
296    pub changed: bool,
297}
298
299/// The count.
300#[derive(Debug, Serialize)]
301pub struct TallyView {
302    /// `present >= quorum`, or none was required.
303    pub met_quorum: bool,
304    /// Always `met_quorum`; named so the client has one field to gate on.
305    pub decided: bool,
306    /// The winner is only provisional.
307    pub provisional: bool,
308    /// Winning label.
309    pub winner: String,
310    /// Configured panel size.
311    pub judges: usize,
312    /// Judges who contributed.
313    pub present: usize,
314    /// Judges required.
315    pub quorum: usize,
316    /// Why no panel sat.
317    pub uncontested: Option<String>,
318    /// First choices per label.
319    pub first_choice: BTreeMap<String, usize>,
320    /// `none`, `one`, `unanimous` or `split`.
321    pub initial: &'static str,
322    /// Final votes agreed.
323    pub unanimous_final: bool,
324    /// Judges who moved.
325    pub changed_votes: usize,
326    /// How a tie was broken.
327    pub tie_break: Option<String>,
328    /// Seats lost to a rate limit.
329    pub rate_limited: Vec<String>,
330}
331
332/// One review round.
333#[derive(Debug, Serialize)]
334pub struct RoundView {
335    /// 1-based.
336    pub round: usize,
337    /// `clean`, `open` or `incomplete`.
338    pub status: &'static str,
339    /// Tone of the round.
340    pub tone: Tone,
341    /// Reviewed commit, abbreviated.
342    pub head: String,
343    /// Commit e2e ran against, when recorded.
344    pub verified_head: Option<String>,
345    /// Findings raised across reviewers.
346    pub raised: usize,
347    /// Blocking findings the round recorded.
348    pub blocking: usize,
349    /// Reviewers that answered.
350    pub answered: usize,
351    /// Reviewers expected.
352    pub expected: usize,
353    /// Seats that never answered, `review-N: why`.
354    pub missing: Vec<String>,
355    /// The e2e leg.
356    pub e2e: E2eView,
357    /// The panel's verdict.
358    pub verdict: Option<VerdictView>,
359    /// The fixer's report.
360    pub fix: Option<FixView>,
361    /// Per-command e2e results.
362    pub commands: Vec<CommandView>,
363    /// Per-reviewer votes.
364    pub reviewers: Vec<ReviewerRow>,
365    /// Every finding, plus declined ones the fixer named.
366    pub findings: Vec<FindingView>,
367    /// Revotes after a split.
368    pub reconsideration: Vec<RevoteRow>,
369    /// Starts expanded: the last round, or any round not clean.
370    pub default_open: bool,
371}
372
373/// The e2e leg of a round.
374#[derive(Debug, Serialize)]
375pub struct E2eView {
376    /// `pass`, `fail`, `blocked`, `deferred` or `not_configured`.
377    pub state: &'static str,
378    /// The failure was a build or link failure, not a verdict on the patch.
379    pub build_failed: bool,
380    /// Why it was deferred.
381    pub defer_reason: Option<String>,
382    /// Retried once.
383    pub retried: bool,
384}
385
386/// A verdict vote.
387#[derive(Debug, Serialize)]
388pub struct VerdictView {
389    /// `approve`, `approve_with_findings` or `reject`.
390    pub vote: &'static str,
391    /// Operator wording.
392    pub label: &'static str,
393    /// The panel split on the way here.
394    pub split: bool,
395}
396
397/// What the fixer reported.
398#[derive(Debug, Serialize)]
399pub struct FixView {
400    /// Agent.
401    pub agent: String,
402    /// Finding ids the fixer reported acting on.
403    pub addressed: usize,
404    /// Finding ids it declined.
405    pub rejected: usize,
406    /// The fix produced a commit.
407    pub committed: bool,
408    /// The tree changed this round.
409    pub tree_changed: bool,
410    /// Why the adoption report is missing; addressed/rejected are unknown then.
411    pub report_lost: Option<String>,
412    /// How the report was recovered, when it needed to be.
413    pub continuation: Option<String>,
414}
415
416/// A reviewer's own line.
417#[derive(Debug, Serialize)]
418pub struct ReviewerRow {
419    /// Seat number.
420    pub reviewer: usize,
421    /// Agent.
422    pub agent: String,
423    /// Initial vote.
424    pub vote: Option<&'static str>,
425    /// Why it produced nothing.
426    pub failed: Option<String>,
427}
428
429/// A revote.
430#[derive(Debug, Serialize)]
431pub struct RevoteRow {
432    /// Seat number.
433    pub reviewer: usize,
434    /// The vote, `None` when it was lost.
435    pub vote: Option<&'static str>,
436    /// Reason, or the failure.
437    pub reason: String,
438}
439
440/// One finding.
441#[derive(Debug, Serialize)]
442pub struct FindingView {
443    /// magi-assigned id.
444    pub id: String,
445    /// Reviewer seat that raised it; `None` for a declined id with no record.
446    pub reviewer: Option<usize>,
447    /// The real severity: `nit`, `minor`, `major` or `blocker`.
448    pub severity: Option<&'static str>,
449    /// One-line summary.
450    pub title: String,
451    /// File.
452    pub file: Option<String>,
453    /// Line.
454    pub line: Option<u32>,
455    /// Holds the merge (`Severity::blocks`).
456    pub blocking: bool,
457    /// `open`, `fixed` (the fixer *reported* acting on it) or `declined`.
458    pub state: &'static str,
459    /// Why the fixer declined.
460    pub declined_why: Option<String>,
461}
462
463/// A shell command's outcome.
464#[derive(Debug, Serialize)]
465pub struct CommandView {
466    /// The command.
467    pub command: String,
468    /// `pass`, `fail` or `blocked`.
469    pub state: &'static str,
470    /// Exit code.
471    pub code: Option<i32>,
472    /// Wall-clock milliseconds.
473    pub duration_ms: u64,
474    /// The last of the output; only for a command that did not pass.
475    pub output_tail: Option<String>,
476}
477
478/// An operator-requested fix.
479#[derive(Debug, Serialize)]
480pub struct OperatorFixView {
481    /// When asked.
482    pub requested_at: String,
483    /// The head was stale.
484    pub stale: bool,
485    /// Why.
486    pub reason: String,
487    /// Findings and what happened to each.
488    pub findings: Vec<OperatorFindingRow>,
489    /// The follow-up review's run.
490    pub follow_up_review_run: Option<String>,
491    /// Committed, but nothing re-verifies it.
492    pub unverified_commit: bool,
493}
494
495/// One operator-fix finding.
496#[derive(Debug, Serialize)]
497pub struct OperatorFindingRow {
498    /// Id.
499    pub id: String,
500    /// Severity.
501    pub severity: &'static str,
502    /// Title.
503    pub title: String,
504    /// `pending`, `addressed`, `rejected` or `unreported`.
505    pub outcome: &'static str,
506    /// Why, for a rejection.
507    pub why: Option<String>,
508}
509
510/// The base-sync state.
511#[derive(Debug, Serialize)]
512pub struct BaseSyncView {
513    /// Base branch.
514    pub base_branch: String,
515    /// Tip checked against.
516    pub tip: String,
517    /// `already_in`, `conflict`, `in_sync` or `behind`.
518    pub state: &'static str,
519    /// Commits behind.
520    pub behind: usize,
521    /// Rebase attempts.
522    pub attempts: usize,
523    /// First line of the conflict.
524    pub conflict: Option<String>,
525    /// `a as b (proof match)`, when already in the base.
526    pub already_in: Option<String>,
527}
528
529/// What happened to the branch.
530#[derive(Debug, Serialize)]
531pub struct MergeView {
532    /// Merge mode, lowercase.
533    pub mode: String,
534    /// `not_landed_by_design`, `ok`, `empty` or `not_merged`.
535    pub state: &'static str,
536    /// First line of magi's own detail.
537    pub detail: String,
538    /// The branch left unmerged, for the by-design case.
539    pub branch: Option<String>,
540    /// A squash merge inherits the placeholder subject.
541    pub squash_caveat: bool,
542    /// Checks that were red at merge.
543    pub red_at_merge: Vec<String>,
544}
545
546/// A filed follow-up.
547#[derive(Debug, Serialize)]
548pub struct FollowupRow {
549    /// Task id, abbreviated.
550    pub task: String,
551    /// Finding ids it carries.
552    pub findings: Vec<String>,
553}
554
555/// The release bump.
556#[derive(Debug, Serialize)]
557pub struct ReleaseBumpView {
558    /// Version.
559    pub version: Option<String>,
560    /// Pull request.
561    pub pr_url: Option<String>,
562    /// `enabled`, `merged_directly`, `local`, `failed` or `not_enabled`.
563    pub automerge: &'static str,
564    /// First line of the problem.
565    pub problem: Option<String>,
566    /// What a person must do.
567    pub action_required: Option<String>,
568}
569
570/// Jobs of one seat.
571#[derive(Debug, Serialize)]
572pub struct JobGroup {
573    /// Graph node.
574    pub node: String,
575    /// Seat.
576    pub seat: String,
577    /// Commands.
578    pub jobs: Vec<JobRow>,
579}
580
581/// One reported command.
582#[derive(Debug, Serialize)]
583pub struct JobRow {
584    /// The CLI's id.
585    pub id: String,
586    /// `completed`, `failed` or `unknown`.
587    pub status: &'static str,
588    /// Exit code.
589    pub exit_code: Option<i32>,
590    /// Review round.
591    pub round: Option<usize>,
592    /// When the evidence was read.
593    pub checked_at: String,
594    /// The command.
595    pub description: String,
596    /// Tail of its output.
597    pub result_summary: String,
598}
599
600fn short(commit: &str) -> String {
601    commit.chars().take(7).collect()
602}
603
604fn first_line(text: &str) -> String {
605    text.lines()
606        .map(str::trim)
607        .find(|l| !l.is_empty())
608        .unwrap_or("")
609        .to_owned()
610}
611
612fn severity_name(s: Severity) -> &'static str {
613    match s {
614        Severity::Nit => "nit",
615        Severity::Minor => "minor",
616        Severity::Major => "major",
617        Severity::Blocker => "blocker",
618    }
619}
620
621fn vote_name(v: ReviewVote) -> &'static str {
622    match v {
623        ReviewVote::Approve => "approve",
624        ReviewVote::ApproveWithFindings => "approve_with_findings",
625        ReviewVote::Reject => "reject",
626    }
627}
628
629fn command(o: &CommandOutcome, warn_instead_of_fail: bool) -> CommandView {
630    let state = if o.resource_blocked {
631        "blocked"
632    } else if o.ok() {
633        "pass"
634    } else if warn_instead_of_fail {
635        "warn"
636    } else {
637        "fail"
638    };
639    CommandView {
640        command: o.command.clone(),
641        state,
642        code: o.code,
643        duration_ms: o.duration_ms,
644        output_tail: (!o.ok()).then(|| tail(&o.output_tail, OUTPUT_TAIL)),
645    }
646}
647
648fn status_tone(state: &RunState) -> Tone {
649    use crate::run::RunStatus as S;
650    if state.needs_attention() {
651        return Tone::Warn;
652    }
653    match state.status {
654        S::Merged | S::Ready => Tone::Ok,
655        S::Failed => Tone::Fail,
656        S::Stalled | S::Blocked => Tone::Warn,
657        _ => Tone::Neutral,
658    }
659}
660
661fn round_view(r: &ReviewRound, last: bool) -> RoundView {
662    let raised = r.reviews.iter().map(|x| x.findings.len()).sum();
663    let status = if r.incomplete() {
664        "incomplete"
665    } else if r.clean {
666        "clean"
667    } else {
668        "open"
669    };
670    let e2e_state = match r.e2e_status() {
671        E2eStatus::NotConfigured => "not_configured",
672        E2eStatus::Deferred => "deferred",
673        E2eStatus::Passed => "pass",
674        E2eStatus::Failed => "fail",
675        E2eStatus::ResourceBlocked => "blocked",
676    };
677    let e2e = E2eView {
678        state: e2e_state,
679        build_failed: r.e2e_status() == E2eStatus::Failed
680            && r.e2e.iter().any(CommandOutcome::build_failed),
681        defer_reason: r
682            .e2e_defer_reason
683            .clone()
684            .filter(|_| e2e_state == "deferred"),
685        retried: r.verify_retried,
686    };
687    let tone = if e2e_state == "fail" && !e2e.build_failed {
688        Tone::Fail
689    } else if status == "clean" {
690        Tone::Ok
691    } else {
692        Tone::Warn
693    };
694    let missing = if r.incomplete() {
695        r.reviews
696            .iter()
697            .filter_map(|x| {
698                x.failed
699                    .as_ref()
700                    .map(|why| format!("review-{}: {why}", x.reviewer))
701            })
702            .collect()
703    } else {
704        Vec::new()
705    };
706    let fix = r.fix.as_ref().map(|f| FixView {
707        agent: f.agent.clone(),
708        addressed: f.addressed.len(),
709        rejected: f.rejected.len(),
710        committed: f.committed,
711        tree_changed: r.progressed,
712        report_lost: f.failed.clone(),
713        continuation: f.continuation.as_ref().and_then(|c| match c.outcome {
714            ContinuationOutcome::NotNeeded => None,
715            ContinuationOutcome::Resumed => Some(format!("resumed x{}", c.attempts)),
716            ContinuationOutcome::Exhausted => Some(format!("exhausted x{}", c.attempts)),
717            ContinuationOutcome::QuotaLost => Some("quota".to_owned()),
718            ContinuationOutcome::NoSession => Some("no session to resume".to_owned()),
719        }),
720    });
721
722    let mut findings: Vec<FindingView> = Vec::new();
723    for rec in &r.reviews {
724        for f in &rec.findings {
725            findings.push(finding_view(r, rec.reviewer, f));
726        }
727    }
728    // A declined id is shown once, on its own finding. One that matches no
729    // finding (a fixer naming an id the record does not hold) still has to be
730    // visible, never dropped.
731    if let Some(fix) = &r.fix {
732        for rej in &fix.rejected {
733            if !findings.iter().any(|f| f.id == rej.id) {
734                findings.push(FindingView {
735                    id: rej.id.clone(),
736                    reviewer: None,
737                    severity: None,
738                    title: String::new(),
739                    file: None,
740                    line: None,
741                    blocking: false,
742                    state: "declined",
743                    declined_why: Some(rej.why.clone()),
744                });
745            }
746        }
747    }
748
749    RoundView {
750        round: r.round,
751        status,
752        tone,
753        head: short(&r.head),
754        verified_head: r.verified_head.as_deref().map(short),
755        raised,
756        blocking: r.blocking,
757        answered: r.answered,
758        expected: r.expected,
759        missing,
760        e2e,
761        verdict: r.verdict.map(|v| VerdictView {
762            vote: vote_name(v),
763            label: v.label(),
764            split: r.vote_split,
765        }),
766        fix,
767        commands: r.e2e.iter().map(|o| command(o, false)).collect(),
768        reviewers: r
769            .reviews
770            .iter()
771            .map(|x| ReviewerRow {
772                reviewer: x.reviewer,
773                agent: x.agent.clone(),
774                vote: x.vote.map(vote_name),
775                failed: x.failed.clone(),
776            })
777            .collect(),
778        findings,
779        reconsideration: r
780            .reconsideration
781            .iter()
782            .map(|rv| RevoteRow {
783                reviewer: rv.reviewer,
784                vote: rv.vote.map(vote_name),
785                reason: match rv.vote {
786                    Some(_) => rv.reason.clone(),
787                    None => format!("no revote ({})", rv.failed.as_deref().unwrap_or("unknown")),
788                },
789            })
790            .collect(),
791        default_open: last || status != "clean",
792    }
793}
794
795fn finding_view(r: &ReviewRound, reviewer: usize, f: &Finding) -> FindingView {
796    let fixed = r
797        .fix
798        .as_ref()
799        .is_some_and(|fix| fix.addressed.contains(&f.id));
800    let declined = r
801        .fix
802        .as_ref()
803        .and_then(|fix| fix.rejected.iter().find(|x| x.id == f.id));
804    let (state, declined_why) = match (fixed, declined) {
805        (true, _) => ("fixed", None),
806        (false, Some(rej)) => ("declined", Some(rej.why.clone())),
807        (false, None) => ("open", None),
808    };
809    FindingView {
810        id: f.id.clone(),
811        reviewer: Some(reviewer),
812        severity: Some(severity_name(f.severity)),
813        title: f.title.clone(),
814        file: f.file.clone(),
815        line: f.line,
816        blocking: f.severity.blocks(),
817        state,
818        declined_why,
819    }
820}
821
822fn jobs_section(state: &RunState) -> Option<Section> {
823    let codex = state
824        .config
825        .agents
826        .iter()
827        .any(|a| a.kind == crate::config::AgentKind::Codex);
828    if state.jobs.is_empty() && !codex {
829        return None;
830    }
831    let mut by_seat: BTreeMap<(&str, &str), Vec<&crate::run::JobRecord>> = BTreeMap::new();
832    for j in &state.jobs {
833        by_seat
834            .entry((j.node.as_str(), j.seat.as_str()))
835            .or_default()
836            .push(j);
837    }
838    let groups = by_seat
839        .into_iter()
840        .map(|((node, seat), records)| JobGroup {
841            node: node.to_owned(),
842            seat: seat.to_owned(),
843            jobs: records
844                .into_iter()
845                .map(|j| JobRow {
846                    id: j.id.clone(),
847                    status: match j.status {
848                        JobStatus::Completed => "completed",
849                        JobStatus::Failed => "failed",
850                        JobStatus::Unknown => "unknown",
851                    },
852                    exit_code: j.exit_code,
853                    round: j.round,
854                    checked_at: j.checked_at.to_string(),
855                    description: first_line(&j.description),
856                    result_summary: tail(&j.result_summary, OUTPUT_TAIL),
857                })
858                .collect(),
859        })
860        .collect();
861    Some(Section::Jobs {
862        head: head("background jobs", Tone::Neutral, false),
863        groups,
864        coverage: "adapter coverage: codex only today; other backends, and a command a CLI \
865                   never reported finishing, leave no entry here - that is unknown, never \
866                   \"nothing ran\"",
867        empty_note: state.jobs.is_empty().then_some(
868            "no completed command evidence yet for this run (see active seats for what is \
869             still mid-turn)",
870        ),
871    })
872}
873
874/// Build the view. `live` is [`RunState::liveness`], exactly what the text
875/// route passes to `report::active_seats`.
876pub fn build(state: &RunState, live: Liveness) -> RunReportView {
877    let mut sections: Vec<Section> = Vec::new();
878    let met_quorum = state.tally.as_ref().is_none_or(|t| t.met_quorum);
879
880    sections.push(Section::Candidates {
881        head: head(
882            "candidates",
883            if state.candidates.iter().any(|c| c.failed.is_some()) {
884                Tone::Warn
885            } else {
886                Tone::Neutral
887            },
888            true,
889        ),
890        items: state
891            .candidates
892            .iter()
893            .map(|c| {
894                let is_winner = state.tally.as_ref().is_some_and(|t| t.winner == c.label);
895                let (st, detail) = match (&c.failed, c.empty, &c.verified_noop) {
896                    (Some(e), _, _) => ("failed", Some(e.clone())),
897                    (None, true, Some(ev)) => ("verified_noop", Some(first_line(ev))),
898                    (None, true, None) => ("no_change", None),
899                    _ => (
900                        "changed",
901                        Some(first_line(&c.summary)).filter(|s| !s.is_empty()),
902                    ),
903                };
904                CandidateRow {
905                    label: c.label.to_string(),
906                    agent: c.agent.clone(),
907                    state: st,
908                    detail,
909                    files: c.files,
910                    commits: c.commits,
911                    duration_secs: c.duration_ms / 1000,
912                    winner: is_winner && met_quorum,
913                    provisional_winner: is_winner && !met_quorum,
914                }
915            })
916            .collect(),
917    });
918
919    if !state.judgements.is_empty() {
920        sections.push(Section::Judging {
921            head: head("blind judging", Tone::Neutral, false),
922            judges: state
923                .judgements
924                .iter()
925                .map(|j| JudgeRow {
926                    judge: j.judge,
927                    agent: j.agent.clone(),
928                    ranking: j.ranking.iter().collect(),
929                    confidence: j.confidence,
930                    failed: j.failed.clone(),
931                })
932                .collect(),
933            deliberation: state
934                .tally
935                .as_ref()
936                .filter(|t| t.deliberated)
937                .map(|_| {
938                    state
939                        .deliberation
940                        .iter()
941                        .flat_map(|round| {
942                            round.turns.iter().map(move |turn| {
943                                format!(
944                                    "r{} judge {} -> {}",
945                                    round.round,
946                                    turn.judge,
947                                    turn.tentative.map_or("-".to_owned(), |c| c.to_string())
948                                )
949                            })
950                        })
951                        .collect()
952                })
953                .unwrap_or_default(),
954            votes: state
955                .votes
956                .iter()
957                .map(|v| VoteRow {
958                    judge: v.judge,
959                    agent: v.agent.clone(),
960                    vote: v.vote.unwrap_or('?').to_string(),
961                    changed: v.changed,
962                })
963                .collect(),
964        });
965    }
966
967    if let Some(t) = &state.tally {
968        let initial = match (t.rankings, t.unanimous_initial) {
969            (0, _) => "none",
970            (1, _) => "one",
971            (_, true) => "unanimous",
972            (_, false) => "split",
973        };
974        sections.push(Section::Tally {
975            head: head(
976                "tally",
977                if t.met_quorum { Tone::Ok } else { Tone::Fail },
978                true,
979            ),
980            tally: TallyView {
981                met_quorum: t.met_quorum,
982                decided: t.met_quorum,
983                provisional: !t.met_quorum,
984                winner: t.winner.to_string(),
985                judges: t.judges,
986                present: t.present,
987                quorum: t.quorum,
988                uncontested: t.uncontested.clone(),
989                first_choice: t
990                    .first_choice
991                    .iter()
992                    .map(|(k, v)| (k.to_string(), *v))
993                    .collect(),
994                initial,
995                unanimous_final: t.unanimous_final,
996                changed_votes: t.changed_votes,
997                tie_break: t.tie_break.clone(),
998                rate_limited: state.quota.iter().map(|q| q.seat.clone()).collect(),
999            },
1000        });
1001    }
1002
1003    let notes = |key: &'static str, title: &str, tone: Tone, lines: Vec<String>| {
1004        (!lines.is_empty()).then(|| Section::Notes {
1005            head: head(title, tone, true),
1006            key,
1007            lines,
1008        })
1009    };
1010    sections.extend(notes(
1011        "handovers",
1012        "seats handed over",
1013        Tone::Warn,
1014        state
1015            .handovers
1016            .iter()
1017            .map(|h| {
1018                format!(
1019                    "{}  {} -> {}  ({}: {})",
1020                    h.seat, h.from, h.to, h.node, h.reason
1021                )
1022            })
1023            .collect(),
1024    ));
1025    sections.extend(notes(
1026        "withheld",
1027        "withheld from commit",
1028        Tone::Warn,
1029        state
1030            .withheld
1031            .iter()
1032            .map(|w| {
1033                format!(
1034                    "{}  ({} lockfile; the directory uses {})",
1035                    w.path, w.manager, w.kept_by
1036                )
1037            })
1038            .collect(),
1039    ));
1040
1041    if !state.reviews.is_empty() {
1042        let n = state.reviews.len();
1043        let rounds: Vec<RoundView> = state
1044            .reviews
1045            .iter()
1046            .enumerate()
1047            .map(|(i, r)| round_view(r, i + 1 == n))
1048            .collect();
1049        let handed_off = state.handed_off_with_open_findings().then(|| {
1050            format!(
1051                "handed off with {} finding(s) still open - gate and e2e were green; see above \
1052                 for what a person should still look at",
1053                state.open_findings().len()
1054            )
1055        });
1056        let tone = if handed_off.is_some() {
1057            Tone::Warn
1058        } else {
1059            rounds.last().map_or(Tone::Neutral, |r| r.tone)
1060        };
1061        sections.push(Section::Review {
1062            head: head("review + verification", tone, true),
1063            rounds,
1064            handed_off,
1065        });
1066    }
1067
1068    if !state.operator_fixes.is_empty() {
1069        sections.push(Section::OperatorFixes {
1070            head: head("operator fix(es)", Tone::Neutral, true),
1071            requests: state
1072                .operator_fixes
1073                .iter()
1074                .map(|req| OperatorFixView {
1075                    requested_at: req.requested_at.to_string(),
1076                    stale: req.stale,
1077                    reason: req.reason.clone(),
1078                    findings: req
1079                        .findings
1080                        .iter()
1081                        .map(|f| {
1082                            let (outcome, why) = match &f.outcome {
1083                                OperatorFixOutcome::Pending => ("pending", None),
1084                                OperatorFixOutcome::Addressed => ("addressed", None),
1085                                OperatorFixOutcome::Rejected { why } => {
1086                                    ("rejected", Some(why.clone()))
1087                                }
1088                                OperatorFixOutcome::Unreported => ("unreported", None),
1089                            };
1090                            OperatorFindingRow {
1091                                id: f.id.clone(),
1092                                severity: severity_name(f.severity),
1093                                title: f.title.clone(),
1094                                outcome,
1095                                why,
1096                            }
1097                        })
1098                        .collect(),
1099                    follow_up_review_run: req.follow_up_review_run.clone(),
1100                    unverified_commit: req.follow_up_review_run.is_none()
1101                        && req.fix.as_ref().is_some_and(|fx| fx.committed),
1102                })
1103                .collect(),
1104        });
1105    }
1106
1107    if let Some(bs) = &state.base_sync {
1108        let (st, tone) = if bs.already_in.is_some() {
1109            ("already_in", Tone::Neutral)
1110        } else if bs.conflict.is_some() {
1111            ("conflict", Tone::Fail)
1112        } else if bs.behind == 0 {
1113            ("in_sync", Tone::Ok)
1114        } else {
1115            ("behind", Tone::Warn)
1116        };
1117        sections.push(Section::BaseSync {
1118            head: head("base sync", tone, true),
1119            base_sync: BaseSyncView {
1120                base_branch: state.base_branch.clone(),
1121                tip: short(&bs.tip),
1122                state: st,
1123                behind: bs.behind,
1124                attempts: bs.attempts,
1125                conflict: bs.conflict.as_deref().map(first_line),
1126                already_in: bs
1127                    .already_in
1128                    .as_ref()
1129                    .map(|a| format!("{} ({} match)", a.names(), a.proof.as_str())),
1130            },
1131        });
1132    }
1133
1134    if !state.pre_gate.is_empty() {
1135        let all_ok = state.pre_gate.iter().all(CommandOutcome::ok);
1136        sections.push(Section::PreGate {
1137            head: head(
1138                "pre_gate",
1139                if all_ok { Tone::Ok } else { Tone::Warn },
1140                !all_ok,
1141            ),
1142            commands: state.pre_gate.iter().map(|o| command(o, true)).collect(),
1143            committed: state.pre_gate_commit.as_deref().map(short),
1144        });
1145    }
1146
1147    match state.gate_status() {
1148        GateStatus::NotRun => {}
1149        gs => {
1150            let st = match gs {
1151                GateStatus::PassedWithNoCommands => "passed_no_commands",
1152                GateStatus::Passed => "passed",
1153                _ => "failed",
1154            };
1155            sections.push(Section::Gate {
1156                head: head("gate", if gs.ok() { Tone::Ok } else { Tone::Fail }, true),
1157                status: st,
1158                commands: state.gate.iter().map(|o| command(o, false)).collect(),
1159            });
1160        }
1161    }
1162
1163    if let Some(m) = &state.merge {
1164        let by_design = m.mode == MergeMode::None;
1165        let (st, tone) = if by_design {
1166            ("not_landed_by_design", Tone::Neutral)
1167        } else if m.ok {
1168            ("ok", Tone::Ok)
1169        } else if m.empty {
1170            ("empty", Tone::Warn)
1171        } else {
1172            ("not_merged", Tone::Warn)
1173        };
1174        sections.push(Section::Merge {
1175            head: head("merge", tone, true),
1176            merge: MergeView {
1177                mode: format!("{:?}", m.mode).to_lowercase(),
1178                state: st,
1179                detail: m.detail.lines().next().unwrap_or("").to_owned(),
1180                branch: by_design
1181                    .then(|| state.winner().map(|w| w.branch.clone()))
1182                    .flatten(),
1183                squash_caveat: by_design && state.config.merge.style == MergeStyle::Squash,
1184                red_at_merge: state
1185                    .pr
1186                    .as_ref()
1187                    .map(|pr| pr.red_at_merge.clone())
1188                    .unwrap_or_default(),
1189            },
1190        });
1191    }
1192
1193    if !state.followups.is_empty() {
1194        sections.push(Section::Followups {
1195            head: head("follow-ups", Tone::Neutral, true),
1196            items: state
1197                .followups
1198                .iter()
1199                .map(|f| FollowupRow {
1200                    task: crate::queue::short(&f.task).to_owned(),
1201                    findings: f.findings.clone(),
1202                })
1203                .collect(),
1204        });
1205    }
1206
1207    if let Some(b) = &state.release_bump {
1208        let automerge = if b.automerge_enabled {
1209            "enabled"
1210        } else if b.merged_directly {
1211            "merged_directly"
1212        } else if b.local {
1213            "local"
1214        } else if b.problem.is_some() {
1215            "failed"
1216        } else {
1217            "not_enabled"
1218        };
1219        sections.push(Section::ReleaseBump {
1220            head: head(
1221                "release bump",
1222                if automerge == "failed" || b.action_required.is_some() {
1223                    Tone::Warn
1224                } else {
1225                    Tone::Neutral
1226                },
1227                true,
1228            ),
1229            bump: ReleaseBumpView {
1230                version: b.version.clone(),
1231                pr_url: b.pr_url.clone(),
1232                automerge,
1233                problem: b.problem.as_deref().map(first_line),
1234                action_required: b.action_required.clone(),
1235            },
1236        });
1237    }
1238
1239    sections.extend(notes(
1240        "leaks",
1241        "blindness warnings",
1242        Tone::Warn,
1243        state
1244            .leaks
1245            .iter()
1246            .map(|l| format!("{} x{} in {}", l.token, l.count, l.site))
1247            .collect(),
1248    ));
1249
1250    if let Some(w) = state.winner()
1251        && !w.folded
1252    {
1253        sections.push(Section::WinnerWorktree {
1254            head: head("winner worktree", Tone::Neutral, true),
1255            worktree: w.worktree.display().to_string(),
1256            branch: w.branch.clone(),
1257            provisional: !met_quorum,
1258        });
1259    }
1260
1261    sections.extend(jobs_section(state));
1262
1263    RunReportView {
1264        schema: SCHEMA,
1265        header: Header {
1266            id: state.id.clone(),
1267            status: state.status.display_label().to_owned(),
1268            tone: status_tone(state),
1269            unmerged_by_design: state.unmerged_by_design(),
1270            needs_attention: state.needs_attention(),
1271            repo: state.repo.display().to_string(),
1272            base_branch: state.base_branch.clone(),
1273            base_commit: short(&state.base_commit),
1274            created: state.created_local(),
1275            task: first_line(&state.instruction),
1276            origin: crate::run::origin_label(state.origin.as_ref()),
1277            state_dir: state.dir().display().to_string(),
1278        },
1279        sections,
1280        active_seats: crate::report::active_seats(state, live),
1281    }
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286    use super::*;
1287    use crate::config::Config;
1288    use crate::run::{CommandOutcome, FixRecord, ReviewRecord, Tally};
1289    use crate::verdict::Rejection;
1290    use std::path::PathBuf;
1291
1292    fn state() -> RunState {
1293        // `build` names `state.dir()`, which reads the process-global home.
1294        crate::run::pin_test_home();
1295        RunState::new(
1296            PathBuf::from("/repo/magi"),
1297            "main".to_owned(),
1298            "0123456789abcdef".to_owned(),
1299            "Add a thing\n\nlong".to_owned(),
1300            Config::default(),
1301        )
1302    }
1303
1304    fn finding(id: &str, severity: Severity) -> Finding {
1305        Finding {
1306            id: id.to_owned(),
1307            severity,
1308            file: Some("src/a.rs".to_owned()),
1309            line: Some(7),
1310            title: format!("title {id}"),
1311            detail: String::new(),
1312        }
1313    }
1314
1315    fn cmd(code: Option<i32>, blocked: bool) -> CommandOutcome {
1316        CommandOutcome {
1317            command: "cargo test".to_owned(),
1318            code,
1319            output_tail: "boom\n".repeat(1_000),
1320            duration_ms: 5,
1321            resource_blocked: blocked,
1322        }
1323    }
1324
1325    fn round(n: usize) -> ReviewRound {
1326        let mut r: ReviewRound = serde_json::from_value(serde_json::json!({
1327            "round": n, "head": "abcdef0123", "reviews": []
1328        }))
1329        .expect("round");
1330        r.answered = 1;
1331        r.expected = 1;
1332        r.reviews.push(
1333            serde_json::from_value::<ReviewRecord>(serde_json::json!({
1334                "reviewer": 1, "agent": "a"
1335            }))
1336            .expect("record"),
1337        );
1338        r
1339    }
1340
1341    fn tally(met: bool) -> Tally {
1342        serde_json::from_value(serde_json::json!({
1343            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
1344            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
1345            "unanimous_final": true, "judges": 3, "present": if met { 3 } else { 1 },
1346            "quorum": 2, "met_quorum": met, "rankings": 1
1347        }))
1348        .expect("tally")
1349    }
1350
1351    fn kinds(v: &RunReportView) -> Vec<String> {
1352        serde_json::to_value(&v.sections)
1353            .expect("json")
1354            .as_array()
1355            .expect("array")
1356            .iter()
1357            .map(|s| s["kind"].as_str().expect("kind").to_owned())
1358            .collect()
1359    }
1360
1361    #[test]
1362    fn sections_follow_the_text_report_order() {
1363        let mut s = state();
1364        s.tally = Some(tally(true));
1365        s.reviews.push(round(1));
1366        s.base_sync = Some(
1367            serde_json::from_value(serde_json::json!({"tip": "abc", "behind": 0, "attempts": 0}))
1368                .expect("sync"),
1369        );
1370        s.pre_gate.push(cmd(Some(0), false));
1371        s.jobs.push(
1372            serde_json::from_value(serde_json::json!({
1373                "node": "fix", "seat": "fix", "id": "j1", "description": "ls",
1374                "checked_at": "2026-01-01T00:00:00Z", "status": "Completed",
1375                "exit_code": 0, "source": "codex"
1376            }))
1377            .expect("job"),
1378        );
1379        let v = build(&s, Liveness::Unknown);
1380        assert_eq!(
1381            kinds(&v),
1382            [
1383                "candidates",
1384                "tally",
1385                "review",
1386                "base_sync",
1387                "pre_gate",
1388                "jobs"
1389            ]
1390        );
1391        let json = serde_json::to_value(&v).expect("json");
1392        assert_eq!(json["schema"], SCHEMA);
1393        let jobs = json["sections"].as_array().expect("a").last().expect("l");
1394        assert_eq!(jobs["default_open"], false, "jobs start collapsed");
1395        assert_eq!(jobs["groups"][0]["jobs"][0]["status"], "completed");
1396    }
1397
1398    #[test]
1399    fn a_stalled_tally_is_never_decided() {
1400        let mut s = state();
1401        s.tally = Some(tally(false));
1402        let json = serde_json::to_value(build(&s, Liveness::Unknown)).expect("json");
1403        let t = json["sections"]
1404            .as_array()
1405            .expect("a")
1406            .iter()
1407            .find(|x| x["kind"] == "tally")
1408            .expect("tally");
1409        assert_eq!(t["tally"]["met_quorum"], false);
1410        assert_eq!(t["tally"]["decided"], false);
1411        assert_eq!(t["tally"]["provisional"], true);
1412        assert_eq!(t["tone"], "fail");
1413    }
1414
1415    #[test]
1416    fn findings_keep_severity_blocking_and_what_the_fixer_said() {
1417        let mut s = state();
1418        let mut r = round(1);
1419        r.reviews[0].findings = vec![
1420            finding("R1-1-1", Severity::Blocker),
1421            finding("R1-1-2", Severity::Major),
1422            finding("R1-1-3", Severity::Minor),
1423            finding("R1-1-4", Severity::Nit),
1424        ];
1425        r.fix = Some(
1426            serde_json::from_value::<FixRecord>(serde_json::json!({"agent": "f"})).expect("fix"),
1427        );
1428        let fix = r.fix.as_mut().expect("fix");
1429        fix.addressed = vec!["R1-1-1".to_owned()];
1430        fix.rejected = vec![
1431            Rejection {
1432                id: "R1-1-2".to_owned(),
1433                why: "not a bug".to_owned(),
1434            },
1435            Rejection {
1436                id: "R9-9-9".to_owned(),
1437                why: "unknown id".to_owned(),
1438            },
1439        ];
1440        s.reviews.push(r);
1441        let json = serde_json::to_value(build(&s, Liveness::Unknown)).expect("json");
1442        let review = json["sections"]
1443            .as_array()
1444            .expect("a")
1445            .iter()
1446            .find(|x| x["kind"] == "review")
1447            .expect("review");
1448        let f = &review["rounds"][0]["findings"];
1449        let at = |i: usize| {
1450            (
1451                f[i]["severity"].clone(),
1452                f[i]["blocking"].clone(),
1453                f[i]["state"].clone(),
1454            )
1455        };
1456        assert_eq!(at(0), ("blocker".into(), true.into(), "fixed".into()));
1457        assert_eq!(at(1), ("major".into(), true.into(), "declined".into()));
1458        assert_eq!(f[1]["declined_why"], "not a bug");
1459        assert_eq!(at(2), ("minor".into(), false.into(), "open".into()));
1460        assert_eq!(at(3), ("nit".into(), false.into(), "open".into()));
1461        assert_eq!(
1462            f[4]["id"], "R9-9-9",
1463            "an unmatched declined id is not dropped"
1464        );
1465        assert_eq!(f[4]["state"], "declined");
1466    }
1467
1468    #[test]
1469    fn a_deferred_e2e_is_not_a_failed_one() {
1470        let mut s = state();
1471        let mut deferred = round(1);
1472        deferred.e2e_deferred = true;
1473        deferred.e2e_defer_reason = Some("blocking findings".to_owned());
1474        let mut failed = round(2);
1475        failed.e2e = vec![cmd(Some(1), false)];
1476        let mut blocked = round(3);
1477        blocked.e2e = vec![cmd(None, true)];
1478        let none = round(4);
1479        s.reviews = vec![deferred, failed, blocked, none];
1480        let json = serde_json::to_value(build(&s, Liveness::Unknown)).expect("json");
1481        let rounds = &json["sections"]
1482            .as_array()
1483            .expect("a")
1484            .iter()
1485            .find(|x| x["kind"] == "review")
1486            .expect("review")["rounds"];
1487        let st = |i: usize| {
1488            rounds[i]["e2e"]["state"]
1489                .as_str()
1490                .expect("state")
1491                .to_owned()
1492        };
1493        assert_eq!(
1494            [st(0), st(1), st(2), st(3)],
1495            ["deferred", "fail", "blocked", "not_configured"]
1496        );
1497        assert_eq!(rounds[0]["e2e"]["defer_reason"], "blocking findings");
1498        let tail = rounds[1]["commands"][0]["output_tail"]
1499            .as_str()
1500            .expect("tail");
1501        assert!(tail.len() < 2_100, "output is bounded: {}", tail.len());
1502    }
1503}