Skip to main content

magi/
report_view.rs

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