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