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