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        /// Full deliberation arguments grouped by round.
132        rounds: Vec<DeliberationRoundView>,
133        /// Final votes.
134        votes: Vec<VoteRow>,
135    },
136    /// The mechanical count.
137    Tally {
138        /// Title, tone and default state.
139        #[serde(flatten)]
140        head: Head,
141        /// The count.
142        tally: TallyView,
143    },
144    /// Seats handed over, files withheld, blindness warnings: one line each.
145    Notes {
146        /// Title, tone and default state.
147        #[serde(flatten)]
148        head: Head,
149        /// Which kind of note.
150        key: &'static str,
151        /// One line each.
152        lines: Vec<String>,
153    },
154    /// The graft round: runner-up strengths ported onto the winner.
155    Graft {
156        /// Title, tone and default state.
157        #[serde(flatten)]
158        head: Head,
159        /// The round.
160        graft: GraftView,
161    },
162    /// Review + verification, grouped by round.
163    Review {
164        /// Title, tone and default state.
165        #[serde(flatten)]
166        head: Head,
167        /// One entry per round, oldest first.
168        rounds: Vec<RoundView>,
169        /// Set when the loop handed off with findings still open.
170        handed_off: Option<String>,
171    },
172    /// Fixes the operator asked for.
173    OperatorFixes {
174        /// Title, tone and default state.
175        #[serde(flatten)]
176        head: Head,
177        /// One entry per request.
178        requests: Vec<OperatorFixView>,
179    },
180    /// The winner against the base's tip.
181    BaseSync {
182        /// Title, tone and default state.
183        #[serde(flatten)]
184        head: Head,
185        /// The state.
186        base_sync: BaseSyncView,
187    },
188    /// Mechanical pre-gate commands.
189    PreGate {
190        /// Title, tone and default state.
191        #[serde(flatten)]
192        head: Head,
193        /// Per-command results.
194        commands: Vec<CommandView>,
195        /// Mechanical fixes commit, abbreviated.
196        committed: Option<String>,
197    },
198    /// The final gate.
199    Gate {
200        /// Title, tone and default state.
201        #[serde(flatten)]
202        head: Head,
203        /// `passed`, `passed_no_commands` or `failed`.
204        status: &'static str,
205        /// Per-command results.
206        commands: Vec<CommandView>,
207    },
208    /// What happened to the branch.
209    Merge {
210        /// Title, tone and default state.
211        #[serde(flatten)]
212        head: Head,
213        /// The outcome.
214        merge: MergeView,
215    },
216    /// Tasks filed from open findings.
217    Followups {
218        /// Title, tone and default state.
219        #[serde(flatten)]
220        head: Head,
221        /// Rows.
222        items: Vec<FollowupRow>,
223    },
224    /// Release bump after the merge.
225    ReleaseBump {
226        /// Title, tone and default state.
227        #[serde(flatten)]
228        head: Head,
229        /// The bump.
230        bump: ReleaseBumpView,
231    },
232    /// Where the winner lives.
233    WinnerWorktree {
234        /// Title, tone and default state.
235        #[serde(flatten)]
236        head: Head,
237        /// Worktree path.
238        worktree: String,
239        /// Branch.
240        branch: String,
241        /// The tally behind it did not reach quorum.
242        provisional: bool,
243    },
244    /// Commands the seats' own CLIs reported.
245    Jobs {
246        /// Title, tone and default state.
247        #[serde(flatten)]
248        head: Head,
249        /// Per seat.
250        groups: Vec<JobGroup>,
251        /// Why absence is not evidence.
252        coverage: &'static str,
253        /// No evidence yet, though the roster can report it.
254        empty_note: Option<&'static str>,
255    },
256}
257
258/// One implementation.
259#[derive(Debug, Serialize)]
260pub struct CandidateRow {
261    /// Blind label.
262    pub label: String,
263    /// Agent that wrote it.
264    pub agent: String,
265    /// `failed`, `verified_noop`, `no_change` or `changed`.
266    pub state: &'static str,
267    /// The failure, the no-op evidence or the candidate's summary, one line.
268    pub detail: Option<String>,
269    /// Files touched.
270    pub files: usize,
271    /// Commits ahead of base.
272    pub commits: usize,
273    /// Wall-clock seconds.
274    pub duration_secs: u64,
275    /// The tally's winner, and the tally met quorum.
276    pub winner: bool,
277    /// The tally's winner, but the tally did not meet quorum.
278    pub provisional_winner: bool,
279}
280
281/// One judge's ranking.
282#[derive(Debug, Serialize)]
283pub struct JudgeRow {
284    /// Seat number.
285    pub judge: usize,
286    /// Agent.
287    pub agent: String,
288    /// Ranking, best first; empty when none.
289    pub ranking: String,
290    /// Confidence, when given.
291    pub confidence: Option<u8>,
292    /// Why no ranking, when none.
293    pub failed: Option<String>,
294}
295
296/// One judge's final vote.
297#[derive(Debug, Serialize)]
298pub struct VoteRow {
299    /// Seat number.
300    pub judge: usize,
301    /// Agent.
302    pub agent: String,
303    /// The label voted for, `?` when unreadable.
304    pub vote: String,
305    /// Moved after deliberation.
306    pub changed: bool,
307}
308
309/// One turn in a deliberation round.
310#[derive(Debug, Serialize)]
311pub struct DeliberationTurnView {
312    /// Judge seat number.
313    pub judge: usize,
314    /// Agent.
315    pub agent: String,
316    /// Candidate the judge tentatively backed at the end of the turn.
317    pub tentative: Option<String>,
318    /// Raw argument body.
319    pub body: String,
320    /// Parsed markdown tree of the argument.
321    pub body_md: Vec<crate::md::Node>,
322}
323
324/// One round of deliberation.
325#[derive(Debug, Serialize)]
326pub struct DeliberationRoundView {
327    /// 1-based round number.
328    pub round: usize,
329    /// Turns in this round.
330    pub turns: Vec<DeliberationTurnView>,
331}
332
333/// The count.
334#[derive(Debug, Serialize)]
335pub struct TallyView {
336    /// `present >= quorum`, or none was required.
337    pub met_quorum: bool,
338    /// Always `met_quorum`; named so the client has one field to gate on.
339    pub decided: bool,
340    /// The winner is only provisional.
341    pub provisional: bool,
342    /// Winning label.
343    pub winner: String,
344    /// Configured panel size.
345    pub judges: usize,
346    /// Judges who contributed.
347    pub present: usize,
348    /// Judges required.
349    pub quorum: usize,
350    /// Why no panel sat.
351    pub uncontested: Option<String>,
352    /// First choices per label.
353    pub first_choice: BTreeMap<String, usize>,
354    /// `none`, `one`, `unanimous` or `split`.
355    pub initial: &'static str,
356    /// Final votes agreed.
357    pub unanimous_final: bool,
358    /// Judges who moved.
359    pub changed_votes: usize,
360    /// How a tie was broken.
361    pub tie_break: Option<String>,
362    /// Seats lost to a rate limit.
363    pub rate_limited: Vec<String>,
364}
365
366/// One review round.
367#[derive(Debug, Serialize)]
368pub struct RoundView {
369    /// 1-based.
370    pub round: usize,
371    /// `clean`, `open` or `incomplete`.
372    pub status: &'static str,
373    /// Tone of the round.
374    pub tone: Tone,
375    /// Reviewed commit, abbreviated.
376    pub head: String,
377    /// Commit e2e ran against, when recorded.
378    pub verified_head: Option<String>,
379    /// Findings raised across reviewers.
380    pub raised: usize,
381    /// Blocking findings the round recorded.
382    pub blocking: usize,
383    /// Reviewers that answered.
384    pub answered: usize,
385    /// Reviewers expected.
386    pub expected: usize,
387    /// Seats that never answered, `review-N: why`.
388    pub missing: Vec<String>,
389    /// The e2e leg.
390    pub e2e: E2eView,
391    /// The panel's verdict.
392    pub verdict: Option<VerdictView>,
393    /// The fixer's report.
394    pub fix: Option<FixView>,
395    /// Per-command e2e results.
396    pub commands: Vec<CommandView>,
397    /// Per-reviewer votes.
398    pub reviewers: Vec<ReviewerRow>,
399    /// Every finding, plus declined ones the fixer named.
400    pub findings: Vec<FindingView>,
401    /// Revotes after a split.
402    pub reconsideration: Vec<RevoteRow>,
403    /// Starts expanded: the last round, or any round not clean.
404    pub default_open: bool,
405}
406
407/// The e2e leg of a round.
408#[derive(Debug, Serialize)]
409pub struct E2eView {
410    /// `pass`, `fail`, `blocked`, `deferred` or `not_configured`.
411    pub state: &'static str,
412    /// The failure was a build or link failure, not a verdict on the patch.
413    pub build_failed: bool,
414    /// Why it was deferred.
415    pub defer_reason: Option<String>,
416    /// Retried once.
417    pub retried: bool,
418}
419
420/// A verdict vote.
421#[derive(Debug, Serialize)]
422pub struct VerdictView {
423    /// `approve`, `approve_with_findings` or `reject`.
424    pub vote: &'static str,
425    /// Operator wording.
426    pub label: &'static str,
427    /// The panel split on the way here.
428    pub split: bool,
429}
430
431/// What the fixer reported.
432#[derive(Debug, Serialize)]
433pub struct FixView {
434    /// Agent.
435    pub agent: String,
436    /// Finding ids the fixer reported acting on.
437    pub addressed: usize,
438    /// Finding ids it declined.
439    pub rejected: usize,
440    /// The fix produced a commit.
441    pub committed: bool,
442    /// The tree changed this round.
443    pub tree_changed: bool,
444    /// Why the adoption report is missing; addressed/rejected are unknown then.
445    pub report_lost: Option<String>,
446    /// How the report was recovered, when it needed to be.
447    pub continuation: Option<String>,
448}
449
450/// A reviewer's own line.
451#[derive(Debug, Serialize)]
452pub struct ReviewerRow {
453    /// Seat number.
454    pub reviewer: usize,
455    /// Agent.
456    pub agent: String,
457    /// Initial vote.
458    pub vote: Option<&'static str>,
459    /// Why it produced nothing.
460    pub failed: Option<String>,
461}
462
463/// A revote.
464#[derive(Debug, Serialize)]
465pub struct RevoteRow {
466    /// Seat number.
467    pub reviewer: usize,
468    /// The vote, `None` when it was lost.
469    pub vote: Option<&'static str>,
470    /// Reason, or the failure.
471    pub reason: String,
472}
473
474/// One finding.
475#[derive(Debug, Serialize)]
476pub struct FindingView {
477    /// magi-assigned id.
478    pub id: String,
479    /// Reviewer seat that raised it; `None` for a declined id with no record.
480    pub reviewer: Option<usize>,
481    /// The real severity: `nit`, `minor`, `major` or `blocker`.
482    pub severity: Option<&'static str>,
483    /// One-line summary.
484    pub title: String,
485    /// File.
486    pub file: Option<String>,
487    /// Line.
488    pub line: Option<u32>,
489    /// Holds the merge (`Severity::blocks`).
490    pub blocking: bool,
491    /// `open`, `fixed` (the fixer *reported* acting on it) or `declined`.
492    pub state: &'static str,
493    /// Why the fixer declined.
494    pub declined_why: Option<String>,
495}
496
497/// A shell command's outcome.
498#[derive(Debug, Serialize)]
499pub struct CommandView {
500    /// The command.
501    pub command: String,
502    /// `pass`, `fail` or `blocked`.
503    pub state: &'static str,
504    /// Exit code.
505    pub code: Option<i32>,
506    /// Wall-clock milliseconds.
507    pub duration_ms: u64,
508    /// The last of the output; only for a command that did not pass.
509    pub output_tail: Option<String>,
510}
511
512/// An operator-requested fix.
513#[derive(Debug, Serialize)]
514pub struct OperatorFixView {
515    /// When asked.
516    pub requested_at: String,
517    /// The head was stale.
518    pub stale: bool,
519    /// Why.
520    pub reason: String,
521    /// Findings and what happened to each.
522    pub findings: Vec<OperatorFindingRow>,
523    /// The follow-up review's run.
524    pub follow_up_review_run: Option<String>,
525    /// Committed, but nothing re-verifies it.
526    pub unverified_commit: bool,
527}
528
529/// One operator-fix finding.
530#[derive(Debug, Serialize)]
531pub struct OperatorFindingRow {
532    /// Id.
533    pub id: String,
534    /// Severity.
535    pub severity: &'static str,
536    /// Title.
537    pub title: String,
538    /// `pending`, `addressed`, `rejected` or `unreported`.
539    pub outcome: &'static str,
540    /// Why, for a rejection.
541    pub why: Option<String>,
542}
543
544/// The base-sync state.
545#[derive(Debug, Serialize)]
546pub struct BaseSyncView {
547    /// Base branch.
548    pub base_branch: String,
549    /// Tip checked against.
550    pub tip: String,
551    /// `already_in`, `conflict`, `in_sync` or `behind`.
552    pub state: &'static str,
553    /// Commits behind.
554    pub behind: usize,
555    /// Rebase attempts.
556    pub attempts: usize,
557    /// First line of the conflict.
558    pub conflict: Option<String>,
559    /// `a as b (proof match)`, when already in the base.
560    pub already_in: Option<String>,
561}
562
563/// What happened to the branch.
564#[derive(Debug, Serialize)]
565pub struct MergeView {
566    /// Merge mode, lowercase.
567    pub mode: String,
568    /// `not_landed_by_design`, `ok`, `empty` or `not_merged`.
569    pub state: &'static str,
570    /// First line of magi's own detail.
571    pub detail: String,
572    /// The branch left unmerged, for the by-design case.
573    pub branch: Option<String>,
574    /// A squash merge inherits the placeholder subject.
575    pub squash_caveat: bool,
576    /// Checks that were red at merge.
577    pub red_at_merge: Vec<String>,
578}
579
580/// A filed follow-up.
581#[derive(Debug, Serialize)]
582pub struct FollowupRow {
583    /// Task id, abbreviated.
584    pub task: String,
585    /// Finding ids it carries.
586    pub findings: Vec<String>,
587}
588
589/// The release bump.
590#[derive(Debug, Serialize)]
591pub struct ReleaseBumpView {
592    /// Version.
593    pub version: Option<String>,
594    /// Pull request.
595    pub pr_url: Option<String>,
596    /// `enabled`, `merged_directly`, `local`, `failed` or `not_enabled`.
597    pub automerge: &'static str,
598    /// First line of the problem.
599    pub problem: Option<String>,
600    /// What a person must do.
601    pub action_required: Option<String>,
602}
603
604/// Jobs of one seat.
605#[derive(Debug, Serialize)]
606pub struct JobGroup {
607    /// Graph node.
608    pub node: String,
609    /// Seat.
610    pub seat: String,
611    /// Commands.
612    pub jobs: Vec<JobRow>,
613}
614
615/// One reported command.
616#[derive(Debug, Serialize)]
617pub struct JobRow {
618    /// The CLI's id.
619    pub id: String,
620    /// `completed`, `failed` or `unknown`.
621    pub status: &'static str,
622    /// Exit code.
623    pub exit_code: Option<i32>,
624    /// Review round.
625    pub round: Option<usize>,
626    /// When the evidence was read.
627    pub checked_at: String,
628    /// The command.
629    pub description: String,
630    /// Tail of its output.
631    pub result_summary: String,
632}
633
634fn short(commit: &str) -> String {
635    commit.chars().take(7).collect()
636}
637
638fn first_line(text: &str) -> String {
639    text.lines()
640        .map(str::trim)
641        .find(|l| !l.is_empty())
642        .unwrap_or("")
643        .to_owned()
644}
645
646fn severity_name(s: Severity) -> &'static str {
647    match s {
648        Severity::Nit => "nit",
649        Severity::Minor => "minor",
650        Severity::Major => "major",
651        Severity::Blocker => "blocker",
652    }
653}
654
655fn vote_name(v: ReviewVote) -> &'static str {
656    match v {
657        ReviewVote::Approve => "approve",
658        ReviewVote::ApproveWithFindings => "approve_with_findings",
659        ReviewVote::Reject => "reject",
660    }
661}
662
663fn command(o: &CommandOutcome, warn_instead_of_fail: bool) -> CommandView {
664    let state = if o.resource_blocked {
665        "blocked"
666    } else if o.ok() {
667        "pass"
668    } else if warn_instead_of_fail {
669        "warn"
670    } else {
671        "fail"
672    };
673    CommandView {
674        command: o.command.clone(),
675        state,
676        code: o.code,
677        duration_ms: o.duration_ms,
678        output_tail: (!o.ok()).then(|| tail(&o.output_tail, OUTPUT_TAIL)),
679    }
680}
681
682fn status_tone(state: &RunState) -> Tone {
683    use crate::run::RunStatus as S;
684    if state.needs_attention() {
685        return Tone::Warn;
686    }
687    match state.status {
688        S::Merged | S::Ready => Tone::Ok,
689        S::Failed => Tone::Fail,
690        S::Stalled | S::Blocked => Tone::Warn,
691        _ => Tone::Neutral,
692    }
693}
694
695fn round_view(r: &ReviewRound, last: bool) -> RoundView {
696    let raised = r.reviews.iter().map(|x| x.findings.len()).sum();
697    let status = if r.incomplete() {
698        "incomplete"
699    } else if r.clean {
700        "clean"
701    } else {
702        "open"
703    };
704    let e2e_state = match r.e2e_status() {
705        E2eStatus::NotConfigured => "not_configured",
706        E2eStatus::Deferred => "deferred",
707        E2eStatus::Passed => "pass",
708        E2eStatus::Failed => "fail",
709        E2eStatus::ResourceBlocked => "blocked",
710    };
711    let e2e = E2eView {
712        state: e2e_state,
713        build_failed: r.e2e_status() == E2eStatus::Failed
714            && r.e2e.iter().any(CommandOutcome::build_failed),
715        defer_reason: r
716            .e2e_defer_reason
717            .clone()
718            .filter(|_| e2e_state == "deferred"),
719        retried: r.verify_retried,
720    };
721    let tone = if e2e_state == "fail" && !e2e.build_failed {
722        Tone::Fail
723    } else if status == "clean" {
724        Tone::Ok
725    } else {
726        Tone::Warn
727    };
728    let missing = if r.incomplete() {
729        r.reviews
730            .iter()
731            .filter_map(|x| {
732                x.failed
733                    .as_ref()
734                    .map(|why| format!("review-{}: {why}", x.reviewer))
735            })
736            .collect()
737    } else {
738        Vec::new()
739    };
740    let fix = r.fix.as_ref().map(|f| FixView {
741        agent: f.agent.clone(),
742        addressed: f.addressed.len(),
743        rejected: f.rejected.len(),
744        committed: f.committed,
745        tree_changed: r.progressed,
746        report_lost: f.failed.clone(),
747        continuation: f.continuation.as_ref().and_then(|c| match c.outcome {
748            ContinuationOutcome::NotNeeded => None,
749            ContinuationOutcome::Resumed => Some(format!("resumed x{}", c.attempts)),
750            ContinuationOutcome::Exhausted => Some(format!("exhausted x{}", c.attempts)),
751            ContinuationOutcome::QuotaLost => Some("quota".to_owned()),
752            ContinuationOutcome::NoSession => Some("no session to resume".to_owned()),
753        }),
754    });
755
756    let mut findings: Vec<FindingView> = Vec::new();
757    for rec in &r.reviews {
758        for f in &rec.findings {
759            findings.push(finding_view(r, rec.reviewer, f));
760        }
761    }
762    // A declined id is shown once, on its own finding. One that matches no
763    // finding (a fixer naming an id the record does not hold) still has to be
764    // visible, never dropped.
765    if let Some(fix) = &r.fix {
766        for rej in &fix.rejected {
767            if !findings.iter().any(|f| f.id == rej.id) {
768                findings.push(FindingView {
769                    id: rej.id.clone(),
770                    reviewer: None,
771                    severity: None,
772                    title: String::new(),
773                    file: None,
774                    line: None,
775                    blocking: false,
776                    state: "declined",
777                    declined_why: Some(rej.why.clone()),
778                });
779            }
780        }
781    }
782
783    RoundView {
784        round: r.round,
785        status,
786        tone,
787        head: short(&r.head),
788        verified_head: r.verified_head.as_deref().map(short),
789        raised,
790        blocking: r.blocking,
791        answered: r.answered,
792        expected: r.expected,
793        missing,
794        e2e,
795        verdict: r.verdict.map(|v| VerdictView {
796            vote: vote_name(v),
797            label: v.label(),
798            split: r.vote_split,
799        }),
800        fix,
801        commands: r.e2e.iter().map(|o| command(o, false)).collect(),
802        reviewers: r
803            .reviews
804            .iter()
805            .map(|x| ReviewerRow {
806                reviewer: x.reviewer,
807                agent: x.agent.clone(),
808                vote: x.vote.map(vote_name),
809                failed: x.failed.clone(),
810            })
811            .collect(),
812        findings,
813        reconsideration: r
814            .reconsideration
815            .iter()
816            .map(|rv| RevoteRow {
817                reviewer: rv.reviewer,
818                vote: rv.vote.map(vote_name),
819                reason: match rv.vote {
820                    Some(_) => rv.reason.clone(),
821                    None => format!("no revote ({})", rv.failed.as_deref().unwrap_or("unknown")),
822                },
823            })
824            .collect(),
825        default_open: last || status != "clean",
826    }
827}
828
829fn finding_view(r: &ReviewRound, reviewer: usize, f: &Finding) -> FindingView {
830    let fixed = r
831        .fix
832        .as_ref()
833        .is_some_and(|fix| fix.addressed.contains(&f.id));
834    let declined = r
835        .fix
836        .as_ref()
837        .and_then(|fix| fix.rejected.iter().find(|x| x.id == f.id));
838    let (state, declined_why) = match (fixed, declined) {
839        (true, _) => ("fixed", None),
840        (false, Some(rej)) => ("declined", Some(rej.why.clone())),
841        (false, None) => ("open", None),
842    };
843    FindingView {
844        id: f.id.clone(),
845        reviewer: Some(reviewer),
846        severity: Some(severity_name(f.severity)),
847        title: f.title.clone(),
848        file: f.file.clone(),
849        line: f.line,
850        blocking: f.severity.blocks(),
851        state,
852        declined_why,
853    }
854}
855
856fn jobs_section(state: &RunState) -> Option<Section> {
857    let codex = state
858        .config
859        .agents
860        .iter()
861        .any(|a| a.kind == crate::config::AgentKind::Codex);
862    if state.jobs.is_empty() && !codex {
863        return None;
864    }
865    let mut by_seat: BTreeMap<(&str, &str), Vec<&crate::run::JobRecord>> = BTreeMap::new();
866    for j in &state.jobs {
867        by_seat
868            .entry((j.node.as_str(), j.seat.as_str()))
869            .or_default()
870            .push(j);
871    }
872    let groups = by_seat
873        .into_iter()
874        .map(|((node, seat), records)| JobGroup {
875            node: node.to_owned(),
876            seat: seat.to_owned(),
877            jobs: records
878                .into_iter()
879                .map(|j| JobRow {
880                    id: j.id.clone(),
881                    status: match j.status {
882                        JobStatus::Completed => "completed",
883                        JobStatus::Failed => "failed",
884                        JobStatus::Unknown => "unknown",
885                    },
886                    exit_code: j.exit_code,
887                    round: j.round,
888                    checked_at: j.checked_at.to_string(),
889                    description: first_line(&j.description),
890                    result_summary: tail(&j.result_summary, OUTPUT_TAIL),
891                })
892                .collect(),
893        })
894        .collect();
895    Some(Section::Jobs {
896        head: head("background jobs", Tone::Neutral, false),
897        groups,
898        coverage: "adapter coverage: codex only today; other backends, and a command a CLI \
899                   never reported finishing, leave no entry here - that is unknown, never \
900                   \"nothing ran\"",
901        empty_note: state.jobs.is_empty().then_some(
902            "no completed command evidence yet for this run (see active seats for what is \
903             still mid-turn)",
904        ),
905    })
906}
907
908/// One strength the judges asked to port and where it stands.
909#[derive(Debug, Serialize)]
910pub struct GraftStrengthView {
911    /// Id the prompt gave it.
912    pub id: String,
913    /// Candidate it came from.
914    pub source: String,
915    /// The strength.
916    pub text: String,
917    /// `adopted` (the fixer's report names it), `rejected`, `unreported` (a
918    /// report exists and omits it) or `unknown` (no report to check against).
919    pub state: &'static str,
920    /// The fixer's reason, for a rejection.
921    pub why: Option<String>,
922}
923
924/// One seat change during the graft.
925#[derive(Debug, Serialize)]
926pub struct GraftHandoverView {
927    /// Agent that failed.
928    pub from: String,
929    /// Agent that took over.
930    pub to: String,
931    /// Why.
932    pub reason: String,
933}
934
935/// The graft card.
936#[derive(Debug, Serialize)]
937pub struct GraftView {
938    /// `applied`, `rolled_back` or `unfinished`.
939    pub outcome: &'static str,
940    /// The rollback reason.
941    pub reason: Option<String>,
942    /// The agent that actually answered the round.
943    pub agent: Option<String>,
944    /// Seat changes, oldest first.
945    pub handovers: Vec<GraftHandoverView>,
946    /// The winning candidate.
947    pub winner: Option<String>,
948    /// Candidates the strengths came from.
949    pub sources: Vec<String>,
950    /// Changed lines the graft introduced (supporting information).
951    pub lines: Option<usize>,
952    /// The size limit.
953    pub limit: Option<usize>,
954    /// What the judges asked for, with what the fixer's report confirms.
955    pub requested: Vec<GraftStrengthView>,
956    /// Counts of confirmed, rejected and unconfirmed requests.
957    pub adopted_count: usize,
958    /// Rejected by the fixer.
959    pub rejected_count: usize,
960    /// Neither adopted nor rejected in the report, or no report.
961    pub unconfirmed_count: usize,
962    /// The fixer's own summary of what it ported, as markdown nodes.
963    pub notes_md: Vec<crate::md::Node>,
964    /// The fixer's reasoning before its report, as markdown nodes.
965    pub reasoning_md: Vec<crate::md::Node>,
966    /// Details came from `artifacts/` rather than the run record.
967    pub recovered: bool,
968    /// Details that could not be shown.
969    pub unavailable: Vec<crate::graft_report::Unavailable>,
970    /// Both commits are known, so `GET .../graft.diff` can answer.
971    pub diff_available: bool,
972    /// Abbreviated pre-graft commit.
973    pub pre: String,
974    /// Abbreviated post-graft commit.
975    pub post: Option<String>,
976}
977
978fn graft_view(
979    state: &RunState,
980    g: &crate::run::GraftRecord,
981    rec: Option<&crate::graft_report::Recovered>,
982) -> GraftView {
983    use crate::graft_report::{Standing, Unavailable, standing};
984    use crate::run::GraftOutcome;
985    let mut unavailable: Vec<Unavailable> = Vec::new();
986    let mut recovered = false;
987    let requested_src: Vec<_> = if g.requested.is_empty() {
988        match rec {
989            Some(r) => {
990                recovered |= !r.requested.is_empty();
991                r.requested.clone()
992            }
993            None => Vec::new(),
994        }
995    } else {
996        g.requested.clone()
997    };
998    let adoption = match (&g.adoption, rec) {
999        (Some(a), _) => Some(a.clone()),
1000        (None, Some(r)) if g.adoption_error.is_none() => {
1001            recovered |= r.adoption.is_some();
1002            r.adoption.clone()
1003        }
1004        _ => None,
1005    };
1006    if let Some(r) = rec {
1007        unavailable.extend(r.unavailable.iter().cloned());
1008    }
1009    if let Some(e) = &g.adoption_error {
1010        unavailable.push(Unavailable {
1011            what: "adoption",
1012            reason: e.clone(),
1013        });
1014    }
1015    if g.requested.is_empty() && requested_src.is_empty() && rec.is_none() {
1016        unavailable.push(Unavailable {
1017            what: "requested",
1018            reason: "legacy_record".to_owned(),
1019        });
1020    }
1021    if adoption.is_none() && g.adoption_error.is_none() && rec.is_none() {
1022        unavailable.push(Unavailable {
1023            what: "adoption",
1024            reason: "legacy_record".to_owned(),
1025        });
1026    }
1027    let requested: Vec<GraftStrengthView> = requested_src
1028        .iter()
1029        .map(|s| {
1030            let (st, why) = match adoption.as_ref().map(|a| standing(s, a)) {
1031                None => ("unknown", None),
1032                Some(Standing::Adopted) => ("adopted", None),
1033                Some(Standing::Rejected(w)) => ("rejected", Some(w).filter(|w| !w.is_empty())),
1034                Some(Standing::Unreported) => ("unreported", None),
1035            };
1036            GraftStrengthView {
1037                id: s.id.clone(),
1038                source: s.source.to_string(),
1039                text: s.text.clone(),
1040                state: st,
1041                why,
1042            }
1043        })
1044        .collect();
1045    let count = |st: &str| requested.iter().filter(|r| r.state == st).count();
1046    let (adopted_count, rejected_count) = (count("adopted"), count("rejected"));
1047    let mut sources: Vec<String> = requested.iter().map(|r| r.source.clone()).collect();
1048    sources.dedup();
1049    let (outcome, reason) = match &g.outcome {
1050        GraftOutcome::Applied => ("applied", None),
1051        GraftOutcome::WaitingForRestore => ("waiting_for_restore", None),
1052        GraftOutcome::Running => ("unfinished", None),
1053        GraftOutcome::RolledBack(w) => ("rolled_back", Some(w.clone())),
1054    };
1055    let md = |t: &str| {
1056        if t.trim().is_empty() {
1057            Vec::new()
1058        } else {
1059            crate::md::to_nodes(t, &crate::md::ImageBase::None)
1060        }
1061    };
1062    let short = |c: &str| c.chars().take(12).collect::<String>();
1063    if outcome == "applied" && g.post.is_none() {
1064        unavailable.push(Unavailable {
1065            what: "diff",
1066            reason: "no_post_commit".to_owned(),
1067        });
1068    }
1069    GraftView {
1070        outcome,
1071        reason,
1072        agent: g.agent.clone(),
1073        handovers: state
1074            .handovers
1075            .iter()
1076            .filter(|h| h.node == "graft")
1077            .map(|h| GraftHandoverView {
1078                from: h.from.clone(),
1079                to: h.to.clone(),
1080                reason: h.reason.clone(),
1081            })
1082            .collect(),
1083        winner: state.tally.as_ref().map(|t| t.winner.to_string()),
1084        sources,
1085        lines: g.lines,
1086        limit: g.limit,
1087        unconfirmed_count: requested.len() - adopted_count - rejected_count,
1088        adopted_count,
1089        rejected_count,
1090        requested,
1091        notes_md: adoption.as_ref().map(|a| md(&a.notes)).unwrap_or_default(),
1092        reasoning_md: adoption
1093            .as_ref()
1094            .map(|a| md(&a.reasoning))
1095            .unwrap_or_default(),
1096        recovered,
1097        unavailable,
1098        diff_available: g.post.is_some(),
1099        pre: short(&g.pre),
1100        post: g.post.as_deref().map(short),
1101    }
1102}
1103
1104/// Build the view. `live` is [`RunState::liveness`], exactly what the text
1105/// route passes to `report::active_seats`.
1106pub fn build(state: &RunState, live: Liveness) -> RunReportView {
1107    build_with(state, live, None)
1108}
1109
1110/// [`build`] with the graft details recovered from `artifacts/` (see
1111/// [`crate::graft_report::recover`]); `None` shows only what the record holds.
1112pub fn build_with(
1113    state: &RunState,
1114    live: Liveness,
1115    graft: Option<&crate::graft_report::Recovered>,
1116) -> RunReportView {
1117    let mut sections: Vec<Section> = Vec::new();
1118    let met_quorum = state.tally.as_ref().is_none_or(|t| t.met_quorum);
1119
1120    sections.push(Section::Candidates {
1121        head: head(
1122            "candidates",
1123            if state.candidates.iter().any(|c| c.failed.is_some()) {
1124                Tone::Warn
1125            } else {
1126                Tone::Neutral
1127            },
1128            true,
1129        ),
1130        items: state
1131            .candidates
1132            .iter()
1133            .map(|c| {
1134                let is_winner = state.tally.as_ref().is_some_and(|t| t.winner == c.label);
1135                let (st, detail) = match (&c.failed, c.empty, &c.verified_noop) {
1136                    (Some(e), _, _) => ("failed", Some(e.clone())),
1137                    (None, true, Some(ev)) => ("verified_noop", Some(first_line(ev))),
1138                    (None, true, None) => ("no_change", None),
1139                    _ => (
1140                        "changed",
1141                        Some(first_line(&c.summary)).filter(|s| !s.is_empty()),
1142                    ),
1143                };
1144                CandidateRow {
1145                    label: c.label.to_string(),
1146                    agent: c.agent.clone(),
1147                    state: st,
1148                    detail,
1149                    files: c.files,
1150                    commits: c.commits,
1151                    duration_secs: c.duration_ms / 1000,
1152                    winner: is_winner && met_quorum,
1153                    provisional_winner: is_winner && !met_quorum,
1154                }
1155            })
1156            .collect(),
1157    });
1158
1159    if !state.judgements.is_empty() {
1160        sections.push(Section::Judging {
1161            head: head("blind judging", Tone::Neutral, false),
1162            judges: state
1163                .judgements
1164                .iter()
1165                .map(|j| JudgeRow {
1166                    judge: j.judge,
1167                    agent: j.agent.clone(),
1168                    ranking: j.ranking.iter().collect(),
1169                    confidence: j.confidence,
1170                    failed: j.failed.clone(),
1171                })
1172                .collect(),
1173            deliberation: state
1174                .tally
1175                .as_ref()
1176                .filter(|t| t.deliberated)
1177                .map(|_| {
1178                    state
1179                        .deliberation
1180                        .iter()
1181                        .flat_map(|round| {
1182                            round.turns.iter().map(move |turn| {
1183                                format!(
1184                                    "r{} judge {} -> {}",
1185                                    round.round,
1186                                    turn.judge,
1187                                    turn.tentative.map_or("-".to_owned(), |c| c.to_string())
1188                                )
1189                            })
1190                        })
1191                        .collect()
1192                })
1193                .unwrap_or_default(),
1194            rounds: state
1195                .deliberation
1196                .iter()
1197                .map(|r| DeliberationRoundView {
1198                    round: r.round,
1199                    turns: r
1200                        .turns
1201                        .iter()
1202                        .map(|t| DeliberationTurnView {
1203                            judge: t.judge,
1204                            agent: t.agent.clone(),
1205                            tentative: t.tentative.map(|c| c.to_string()),
1206                            body: t.body.clone(),
1207                            body_md: crate::md::to_nodes(&t.body, &crate::md::ImageBase::None),
1208                        })
1209                        .collect(),
1210                })
1211                .collect(),
1212            votes: state
1213                .votes
1214                .iter()
1215                .map(|v| VoteRow {
1216                    judge: v.judge,
1217                    agent: v.agent.clone(),
1218                    vote: v.vote.unwrap_or('?').to_string(),
1219                    changed: v.changed,
1220                })
1221                .collect(),
1222        });
1223    }
1224
1225    if let Some(t) = &state.tally {
1226        let initial = match (t.rankings, t.unanimous_initial) {
1227            (0, _) => "none",
1228            (1, _) => "one",
1229            (_, true) => "unanimous",
1230            (_, false) => "split",
1231        };
1232        sections.push(Section::Tally {
1233            head: head(
1234                "tally",
1235                if t.met_quorum { Tone::Ok } else { Tone::Fail },
1236                true,
1237            ),
1238            tally: TallyView {
1239                met_quorum: t.met_quorum,
1240                decided: t.met_quorum,
1241                provisional: !t.met_quorum,
1242                winner: t.winner.to_string(),
1243                judges: t.judges,
1244                present: t.present,
1245                quorum: t.quorum,
1246                uncontested: t.uncontested.clone(),
1247                first_choice: t
1248                    .first_choice
1249                    .iter()
1250                    .map(|(k, v)| (k.to_string(), *v))
1251                    .collect(),
1252                initial,
1253                unanimous_final: t.unanimous_final,
1254                changed_votes: t.changed_votes,
1255                tie_break: t.tie_break.clone(),
1256                rate_limited: state.quota.iter().map(|q| q.seat.clone()).collect(),
1257            },
1258        });
1259    }
1260
1261    let notes = |key: &'static str, title: &str, tone: Tone, lines: Vec<String>| {
1262        (!lines.is_empty()).then(|| Section::Notes {
1263            head: head(title, tone, true),
1264            key,
1265            lines,
1266        })
1267    };
1268    sections.extend(notes(
1269        "handovers",
1270        "seats handed over",
1271        Tone::Warn,
1272        state
1273            .handovers
1274            .iter()
1275            .map(|h| {
1276                format!(
1277                    "{}  {} -> {}  ({}: {})",
1278                    h.seat, h.from, h.to, h.node, h.reason
1279                )
1280            })
1281            .collect(),
1282    ));
1283    sections.extend(notes(
1284        "withheld",
1285        "withheld from commit",
1286        Tone::Warn,
1287        state
1288            .withheld
1289            .iter()
1290            .map(|w| {
1291                format!(
1292                    "{}  ({} lockfile; the directory uses {})",
1293                    w.path, w.manager, w.kept_by
1294                )
1295            })
1296            .collect(),
1297    ));
1298
1299    if let Some(g) = &state.graft {
1300        let view = graft_view(state, g, graft);
1301        let tone = match view.outcome {
1302            "applied" if view.unconfirmed_count == 0 && !view.requested.is_empty() => Tone::Ok,
1303            "applied" => Tone::Neutral,
1304            _ => Tone::Warn,
1305        };
1306        sections.push(Section::Graft {
1307            head: head("graft", tone, true),
1308            graft: view,
1309        });
1310    }
1311
1312    if !state.reviews.is_empty() {
1313        let n = state.reviews.len();
1314        let rounds: Vec<RoundView> = state
1315            .reviews
1316            .iter()
1317            .enumerate()
1318            .map(|(i, r)| round_view(r, i + 1 == n))
1319            .collect();
1320        let handed_off = state.handed_off_with_open_findings().then(|| {
1321            format!(
1322                "handed off with {} finding(s) still open - gate and e2e were green; see above \
1323                 for what a person should still look at",
1324                state.open_findings().len()
1325            )
1326        });
1327        let tone = if handed_off.is_some() {
1328            Tone::Warn
1329        } else {
1330            rounds.last().map_or(Tone::Neutral, |r| r.tone)
1331        };
1332        sections.push(Section::Review {
1333            head: head("review + verification", tone, true),
1334            rounds,
1335            handed_off,
1336        });
1337    }
1338
1339    if !state.operator_fixes.is_empty() {
1340        sections.push(Section::OperatorFixes {
1341            head: head("operator fix(es)", Tone::Neutral, true),
1342            requests: state
1343                .operator_fixes
1344                .iter()
1345                .map(|req| OperatorFixView {
1346                    requested_at: req.requested_at.to_string(),
1347                    stale: req.stale,
1348                    reason: req.reason.clone(),
1349                    findings: req
1350                        .findings
1351                        .iter()
1352                        .map(|f| {
1353                            let (outcome, why) = match &f.outcome {
1354                                OperatorFixOutcome::Pending => ("pending", None),
1355                                OperatorFixOutcome::Addressed => ("addressed", None),
1356                                OperatorFixOutcome::Rejected { why } => {
1357                                    ("rejected", Some(why.clone()))
1358                                }
1359                                OperatorFixOutcome::Unreported => ("unreported", None),
1360                            };
1361                            OperatorFindingRow {
1362                                id: f.id.clone(),
1363                                severity: severity_name(f.severity),
1364                                title: f.title.clone(),
1365                                outcome,
1366                                why,
1367                            }
1368                        })
1369                        .collect(),
1370                    follow_up_review_run: req.follow_up_review_run.clone(),
1371                    unverified_commit: req.follow_up_review_run.is_none()
1372                        && req.fix.as_ref().is_some_and(|fx| fx.committed),
1373                })
1374                .collect(),
1375        });
1376    }
1377
1378    if let Some(bs) = &state.base_sync {
1379        let (st, tone) = if bs.already_in.is_some() {
1380            ("already_in", Tone::Neutral)
1381        } else if bs.conflict.is_some() {
1382            ("conflict", Tone::Fail)
1383        } else if bs.behind == 0 {
1384            ("in_sync", Tone::Ok)
1385        } else {
1386            ("behind", Tone::Warn)
1387        };
1388        sections.push(Section::BaseSync {
1389            head: head("base sync", tone, true),
1390            base_sync: BaseSyncView {
1391                base_branch: state.base_branch.clone(),
1392                tip: short(&bs.tip),
1393                state: st,
1394                behind: bs.behind,
1395                attempts: bs.attempts,
1396                conflict: bs.conflict.as_deref().map(first_line),
1397                already_in: bs
1398                    .already_in
1399                    .as_ref()
1400                    .map(|a| format!("{} ({} match)", a.names(), a.proof.as_str())),
1401            },
1402        });
1403    }
1404
1405    if !state.pre_gate.is_empty() {
1406        let all_ok = state.pre_gate.iter().all(CommandOutcome::ok);
1407        sections.push(Section::PreGate {
1408            head: head(
1409                "pre_gate",
1410                if all_ok { Tone::Ok } else { Tone::Warn },
1411                !all_ok,
1412            ),
1413            commands: state.pre_gate.iter().map(|o| command(o, true)).collect(),
1414            committed: state.pre_gate_commit.as_deref().map(short),
1415        });
1416    }
1417
1418    match state.gate_status() {
1419        GateStatus::NotRun => {}
1420        gs => {
1421            let st = match gs {
1422                GateStatus::PassedWithNoCommands => "passed_no_commands",
1423                GateStatus::Passed => "passed",
1424                _ => "failed",
1425            };
1426            sections.push(Section::Gate {
1427                head: head("gate", if gs.ok() { Tone::Ok } else { Tone::Fail }, true),
1428                status: st,
1429                commands: state.gate.iter().map(|o| command(o, false)).collect(),
1430            });
1431        }
1432    }
1433
1434    if let Some(m) = &state.merge {
1435        let by_design = m.mode == MergeMode::None;
1436        let (st, tone) = if by_design {
1437            ("not_landed_by_design", Tone::Neutral)
1438        } else if m.ok {
1439            ("ok", Tone::Ok)
1440        } else if m.empty {
1441            ("empty", Tone::Warn)
1442        } else {
1443            ("not_merged", Tone::Warn)
1444        };
1445        sections.push(Section::Merge {
1446            head: head("merge", tone, true),
1447            merge: MergeView {
1448                mode: format!("{:?}", m.mode).to_lowercase(),
1449                state: st,
1450                detail: m.detail.lines().next().unwrap_or("").to_owned(),
1451                branch: by_design
1452                    .then(|| state.winner().map(|w| w.branch.clone()))
1453                    .flatten(),
1454                squash_caveat: by_design && state.config.merge.style == MergeStyle::Squash,
1455                red_at_merge: state
1456                    .pr
1457                    .as_ref()
1458                    .map(|pr| pr.red_at_merge.clone())
1459                    .unwrap_or_default(),
1460            },
1461        });
1462    }
1463
1464    if !state.followups.is_empty() {
1465        sections.push(Section::Followups {
1466            head: head("follow-ups", Tone::Neutral, true),
1467            items: state
1468                .followups
1469                .iter()
1470                .map(|f| FollowupRow {
1471                    task: crate::queue::short(&f.task).to_owned(),
1472                    findings: f.findings.clone(),
1473                })
1474                .collect(),
1475        });
1476    }
1477
1478    if let Some(b) = &state.release_bump {
1479        let automerge = if b.automerge_enabled {
1480            "enabled"
1481        } else if b.merged_directly {
1482            "merged_directly"
1483        } else if b.local {
1484            "local"
1485        } else if b.problem.is_some() {
1486            "failed"
1487        } else {
1488            "not_enabled"
1489        };
1490        sections.push(Section::ReleaseBump {
1491            head: head(
1492                "release bump",
1493                if automerge == "failed" || b.action_required.is_some() {
1494                    Tone::Warn
1495                } else {
1496                    Tone::Neutral
1497                },
1498                true,
1499            ),
1500            bump: ReleaseBumpView {
1501                version: b.version.clone(),
1502                pr_url: b.pr_url.clone(),
1503                automerge,
1504                problem: b.problem.as_deref().map(first_line),
1505                action_required: b.action_required.clone(),
1506            },
1507        });
1508    }
1509
1510    sections.extend(notes(
1511        "leaks",
1512        "blindness warnings",
1513        Tone::Warn,
1514        state
1515            .leaks
1516            .iter()
1517            .map(|l| format!("{} x{} in {}", l.token, l.count, l.site))
1518            .collect(),
1519    ));
1520
1521    if let Some(w) = state.winner()
1522        && !w.folded
1523    {
1524        sections.push(Section::WinnerWorktree {
1525            head: head("winner worktree", Tone::Neutral, true),
1526            worktree: w.worktree.display().to_string(),
1527            branch: w.branch.clone(),
1528            provisional: !met_quorum,
1529        });
1530    }
1531
1532    sections.extend(jobs_section(state));
1533
1534    RunReportView {
1535        schema: SCHEMA,
1536        header: Header {
1537            id: state.id.clone(),
1538            status: state.status.display_label().to_owned(),
1539            tone: status_tone(state),
1540            unmerged_by_design: state.unmerged_by_design(),
1541            needs_attention: state.needs_attention(),
1542            repo: state.repo.display().to_string(),
1543            base_branch: state.base_branch.clone(),
1544            base_commit: short(&state.base_commit),
1545            created: state.created_local(),
1546            task: first_line(&state.instruction),
1547            origin: crate::run::origin_label(state.origin.as_ref()),
1548            state_dir: state.dir().display().to_string(),
1549        },
1550        sections,
1551        active_seats: crate::report::active_seats(state, live),
1552    }
1553}
1554
1555#[cfg(test)]
1556mod tests {
1557    use super::*;
1558    use crate::config::Config;
1559    use crate::run::{CommandOutcome, FixRecord, ReviewRecord, Tally};
1560    use crate::verdict::Rejection;
1561    use std::path::PathBuf;
1562
1563    fn state() -> RunState {
1564        // `build` names `state.dir()`, which reads the process-global home.
1565        crate::run::pin_test_home();
1566        RunState::new(
1567            PathBuf::from("/repo/magi"),
1568            "main".to_owned(),
1569            "0123456789abcdef".to_owned(),
1570            "Add a thing\n\nlong".to_owned(),
1571            Config::default(),
1572        )
1573    }
1574
1575    fn finding(id: &str, severity: Severity) -> Finding {
1576        Finding {
1577            id: id.to_owned(),
1578            severity,
1579            file: Some("src/a.rs".to_owned()),
1580            line: Some(7),
1581            title: format!("title {id}"),
1582            detail: String::new(),
1583        }
1584    }
1585
1586    fn cmd(code: Option<i32>, blocked: bool) -> CommandOutcome {
1587        CommandOutcome {
1588            command: "cargo test".to_owned(),
1589            code,
1590            output_tail: "boom\n".repeat(1_000),
1591            duration_ms: 5,
1592            resource_blocked: blocked,
1593        }
1594    }
1595
1596    fn round(n: usize) -> ReviewRound {
1597        let mut r: ReviewRound = serde_json::from_value(serde_json::json!({
1598            "round": n, "head": "abcdef0123", "reviews": []
1599        }))
1600        .expect("round");
1601        r.answered = 1;
1602        r.expected = 1;
1603        r.reviews.push(
1604            serde_json::from_value::<ReviewRecord>(serde_json::json!({
1605                "reviewer": 1, "agent": "a"
1606            }))
1607            .expect("record"),
1608        );
1609        r
1610    }
1611
1612    fn tally(met: bool) -> Tally {
1613        serde_json::from_value(serde_json::json!({
1614            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
1615            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
1616            "unanimous_final": true, "judges": 3, "present": if met { 3 } else { 1 },
1617            "quorum": 2, "met_quorum": met, "rankings": 1
1618        }))
1619        .expect("tally")
1620    }
1621
1622    fn kinds(v: &RunReportView) -> Vec<String> {
1623        serde_json::to_value(&v.sections)
1624            .expect("json")
1625            .as_array()
1626            .expect("array")
1627            .iter()
1628            .map(|s| s["kind"].as_str().expect("kind").to_owned())
1629            .collect()
1630    }
1631
1632    #[test]
1633    fn sections_follow_the_text_report_order() {
1634        let mut s = state();
1635        s.tally = Some(tally(true));
1636        s.reviews.push(round(1));
1637        s.base_sync = Some(
1638            serde_json::from_value(serde_json::json!({"tip": "abc", "behind": 0, "attempts": 0}))
1639                .expect("sync"),
1640        );
1641        s.pre_gate.push(cmd(Some(0), false));
1642        s.jobs.push(
1643            serde_json::from_value(serde_json::json!({
1644                "node": "fix", "seat": "fix", "id": "j1", "description": "ls",
1645                "checked_at": "2026-01-01T00:00:00Z", "status": "Completed",
1646                "exit_code": 0, "source": "codex"
1647            }))
1648            .expect("job"),
1649        );
1650        let v = build(&s, Liveness::Unknown);
1651        assert_eq!(
1652            kinds(&v),
1653            [
1654                "candidates",
1655                "tally",
1656                "review",
1657                "base_sync",
1658                "pre_gate",
1659                "jobs"
1660            ]
1661        );
1662        let json = serde_json::to_value(&v).expect("json");
1663        assert_eq!(json["schema"], SCHEMA);
1664        let jobs = json["sections"].as_array().expect("a").last().expect("l");
1665        assert_eq!(jobs["default_open"], false, "jobs start collapsed");
1666        assert_eq!(jobs["groups"][0]["jobs"][0]["status"], "completed");
1667    }
1668
1669    #[test]
1670    fn a_stalled_tally_is_never_decided() {
1671        let mut s = state();
1672        s.tally = Some(tally(false));
1673        let json = serde_json::to_value(build(&s, Liveness::Unknown)).expect("json");
1674        let t = json["sections"]
1675            .as_array()
1676            .expect("a")
1677            .iter()
1678            .find(|x| x["kind"] == "tally")
1679            .expect("tally");
1680        assert_eq!(t["tally"]["met_quorum"], false);
1681        assert_eq!(t["tally"]["decided"], false);
1682        assert_eq!(t["tally"]["provisional"], true);
1683        assert_eq!(t["tone"], "fail");
1684    }
1685
1686    #[test]
1687    fn findings_keep_severity_blocking_and_what_the_fixer_said() {
1688        let mut s = state();
1689        let mut r = round(1);
1690        r.reviews[0].findings = vec![
1691            finding("R1-1-1", Severity::Blocker),
1692            finding("R1-1-2", Severity::Major),
1693            finding("R1-1-3", Severity::Minor),
1694            finding("R1-1-4", Severity::Nit),
1695        ];
1696        r.fix = Some(
1697            serde_json::from_value::<FixRecord>(serde_json::json!({"agent": "f"})).expect("fix"),
1698        );
1699        let fix = r.fix.as_mut().expect("fix");
1700        fix.addressed = vec!["R1-1-1".to_owned()];
1701        fix.rejected = vec![
1702            Rejection {
1703                id: "R1-1-2".to_owned(),
1704                why: "not a bug".to_owned(),
1705            },
1706            Rejection {
1707                id: "R9-9-9".to_owned(),
1708                why: "unknown id".to_owned(),
1709            },
1710        ];
1711        s.reviews.push(r);
1712        let json = serde_json::to_value(build(&s, Liveness::Unknown)).expect("json");
1713        let review = json["sections"]
1714            .as_array()
1715            .expect("a")
1716            .iter()
1717            .find(|x| x["kind"] == "review")
1718            .expect("review");
1719        let f = &review["rounds"][0]["findings"];
1720        let at = |i: usize| {
1721            (
1722                f[i]["severity"].clone(),
1723                f[i]["blocking"].clone(),
1724                f[i]["state"].clone(),
1725            )
1726        };
1727        assert_eq!(at(0), ("blocker".into(), true.into(), "fixed".into()));
1728        assert_eq!(at(1), ("major".into(), true.into(), "declined".into()));
1729        assert_eq!(f[1]["declined_why"], "not a bug");
1730        assert_eq!(at(2), ("minor".into(), false.into(), "open".into()));
1731        assert_eq!(at(3), ("nit".into(), false.into(), "open".into()));
1732        assert_eq!(
1733            f[4]["id"], "R9-9-9",
1734            "an unmatched declined id is not dropped"
1735        );
1736        assert_eq!(f[4]["state"], "declined");
1737    }
1738
1739    #[test]
1740    fn a_deferred_e2e_is_not_a_failed_one() {
1741        let mut s = state();
1742        let mut deferred = round(1);
1743        deferred.e2e_deferred = true;
1744        deferred.e2e_defer_reason = Some("blocking findings".to_owned());
1745        let mut failed = round(2);
1746        failed.e2e = vec![cmd(Some(1), false)];
1747        let mut blocked = round(3);
1748        blocked.e2e = vec![cmd(None, true)];
1749        let none = round(4);
1750        s.reviews = vec![deferred, failed, blocked, none];
1751        let json = serde_json::to_value(build(&s, Liveness::Unknown)).expect("json");
1752        let rounds = &json["sections"]
1753            .as_array()
1754            .expect("a")
1755            .iter()
1756            .find(|x| x["kind"] == "review")
1757            .expect("review")["rounds"];
1758        let st = |i: usize| {
1759            rounds[i]["e2e"]["state"]
1760                .as_str()
1761                .expect("state")
1762                .to_owned()
1763        };
1764        assert_eq!(
1765            [st(0), st(1), st(2), st(3)],
1766            ["deferred", "fail", "blocked", "not_configured"]
1767        );
1768        assert_eq!(rounds[0]["e2e"]["defer_reason"], "blocking findings");
1769        let tail = rounds[1]["commands"][0]["output_tail"]
1770            .as_str()
1771            .expect("tail");
1772        assert!(tail.len() < 2_100, "output is bounded: {}", tail.len());
1773    }
1774    fn graft_json(s: &RunState, rec: Option<&crate::graft_report::Recovered>) -> serde_json::Value {
1775        let json = serde_json::to_value(build_with(s, Liveness::Unknown, rec)).expect("json");
1776        json["sections"]
1777            .as_array()
1778            .expect("a")
1779            .iter()
1780            .find(|x| x["kind"] == "graft")
1781            .cloned()
1782            .unwrap_or(serde_json::Value::Null)
1783    }
1784
1785    fn graft_record(outcome: crate::run::GraftOutcome) -> crate::run::GraftRecord {
1786        use crate::run::{GraftAdoption, GraftRejection, GraftStrength};
1787        crate::run::GraftRecord {
1788            pre: "aaaaaaaaaaaaaaaa".to_owned(),
1789            post: Some("bbbbbbbbbbbbbbbb".to_owned()),
1790            lines: Some(25),
1791            limit: Some(227),
1792            agent: Some("astra".to_owned()),
1793            outcome,
1794            requested: vec![
1795                GraftStrength {
1796                    id: "A1".into(),
1797                    source: 'A',
1798                    text: "Build Persona directly in the test".into(),
1799                },
1800                GraftStrength {
1801                    id: "A2".into(),
1802                    source: 'A',
1803                    text: "Cache the lookup".into(),
1804                },
1805                GraftStrength {
1806                    id: "A3".into(),
1807                    source: 'A',
1808                    text: "Rename the helper".into(),
1809                },
1810            ],
1811            adoption: Some(GraftAdoption {
1812                addressed: vec!["A1".into()],
1813                rejected: vec![GraftRejection {
1814                    id: "A2".into(),
1815                    why: "too large".into(),
1816                }],
1817                notes: "ported <script>alert(1)</script>".into(),
1818                reasoning: "thinking".into(),
1819            }),
1820            adoption_error: None,
1821        }
1822    }
1823
1824    #[test]
1825    fn an_applied_graft_separates_requested_from_confirmed() {
1826        let mut s = state();
1827        s.graft = Some(graft_record(crate::run::GraftOutcome::Applied));
1828        s.handovers.push(crate::run::Handover {
1829            at: jiff::Timestamp::now(),
1830            node: "graft".into(),
1831            seat: "graft".into(),
1832            from: "sonnet".into(),
1833            to: "astra".into(),
1834            reason: "timed out".into(),
1835        });
1836        let g = &graft_json(&s, None)["graft"];
1837        assert_eq!(g["outcome"], "applied");
1838        assert_eq!(g["agent"], "astra");
1839        assert_eq!(g["handovers"][0]["from"], "sonnet");
1840        assert_eq!(g["lines"], 25);
1841        assert_eq!(g["limit"], 227);
1842        let st: Vec<_> = g["requested"]
1843            .as_array()
1844            .expect("a")
1845            .iter()
1846            .map(|r| r["state"].as_str().expect("s").to_owned())
1847            .collect();
1848        assert_eq!(st, ["adopted", "rejected", "unreported"]);
1849        assert_eq!(g["requested"][1]["why"], "too large");
1850        assert_eq!(g["adopted_count"], 1);
1851        assert_eq!(g["unconfirmed_count"], 1);
1852        assert_eq!(g["diff_available"], true);
1853    }
1854
1855    #[test]
1856    fn a_rolled_back_graft_carries_its_reason_and_no_diff() {
1857        let mut s = state();
1858        let mut g = graft_record(crate::run::GraftOutcome::RolledBack("too big".into()));
1859        g.post = None;
1860        s.graft = Some(g);
1861        let v = graft_json(&s, None);
1862        assert_eq!(v["graft"]["outcome"], "rolled_back");
1863        assert_eq!(v["graft"]["reason"], "too big");
1864        assert_eq!(v["graft"]["diff_available"], false);
1865        assert_eq!(v["tone"], "warn");
1866    }
1867
1868    #[test]
1869    fn a_legacy_record_says_unavailable_and_claims_nothing() {
1870        let mut s = state();
1871        let mut g = graft_record(crate::run::GraftOutcome::Applied);
1872        g.requested.clear();
1873        g.adoption = None;
1874        s.graft = Some(g);
1875        let v = graft_json(&s, None);
1876        let reasons: Vec<_> = v["graft"]["unavailable"]
1877            .as_array()
1878            .expect("a")
1879            .iter()
1880            .map(|u| u["reason"].as_str().expect("s"))
1881            .collect();
1882        assert_eq!(reasons, ["legacy_record", "legacy_record"]);
1883        assert_eq!(v["graft"]["requested"].as_array().expect("a").len(), 0);
1884        assert_eq!(v["graft"]["adopted_count"], 0);
1885    }
1886
1887    #[test]
1888    fn recovered_details_fill_a_legacy_record_and_are_marked() {
1889        let mut s = state();
1890        let mut g = graft_record(crate::run::GraftOutcome::Applied);
1891        let requested = std::mem::take(&mut g.requested);
1892        let adoption = g.adoption.take();
1893        s.graft = Some(g);
1894        let rec = crate::graft_report::Recovered {
1895            requested,
1896            adoption,
1897            unavailable: Vec::new(),
1898        };
1899        let g = &graft_json(&s, Some(&rec))["graft"];
1900        assert_eq!(g["recovered"], true);
1901        assert_eq!(g["requested"][0]["state"], "adopted");
1902    }
1903
1904    #[test]
1905    fn agent_text_stays_text_in_the_view() {
1906        let mut s = state();
1907        let mut g = graft_record(crate::run::GraftOutcome::Applied);
1908        g.requested[0].text = "<img src=x onerror=alert(1)>".into();
1909        s.graft = Some(g);
1910        let v = graft_json(&s, None);
1911        assert_eq!(
1912            v["graft"]["requested"][0]["text"],
1913            "<img src=x onerror=alert(1)>"
1914        );
1915        // Prose goes through the markdown tree, which has no raw-HTML node.
1916        let notes = v["graft"]["notes_md"].to_string();
1917        assert!(!notes.contains("\"html\""), "{notes}");
1918    }
1919}