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