Skip to main content

mj_client/
review.rs

1//! Review data shared by Mjolnir's control surfaces.
2
3use mj_core::review::driver::{Resolution, RoleState, RoleStatus, TurnReviewPhase};
4use mj_core::review::lanes::ReviewTier;
5use mj_core::review::verdict::ReviewVerdict;
6
7/// What the host tells a surface about one running review.
8#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
9#[serde(deny_unknown_fields)]
10pub struct RuntimeReviewView {
11    pub session_id: String,
12    pub tier: ReviewTier,
13    pub phase: TurnReviewPhase,
14    pub roles: Vec<RoleStatus>,
15    /// What the review is doing, in one line.
16    pub status: String,
17    /// Present once the review has reached a verdict the user must answer.
18    pub verdict: Option<VerdictView>,
19    /// Forms a reviewing harness is waiting for a person to answer. The
20    /// worker owns them; the host projects them from each role's journal.
21    #[serde(default, skip_serializing_if = "Vec::is_empty")]
22    pub questions: Vec<ReviewerQuestion>,
23}
24
25/// One form a reviewing role asked, and the role that asked it.
26#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct ReviewerQuestion {
29    pub role: String,
30    pub request: mj_core::elicitation::ElicitationRequest,
31}
32
33/// Marks an elicitation id as a reviewing role's form rather than the
34/// primary agent's. Public ids allow only letters, digits, `-`, `_` and `.`,
35/// so the separator is a dot.
36const REVIEWER_QUESTION_PREFIX: &str = "review.";
37
38impl ReviewerQuestion {
39    /// The request as the session's own question list carries it. Its id
40    /// names the role, so an answer goes back to the harness that asked.
41    #[must_use]
42    pub fn session_request(&self) -> mj_core::elicitation::ElicitationRequest {
43        let mut request = self.request.clone();
44        request.id = format!(
45            "{REVIEWER_QUESTION_PREFIX}{}.{}",
46            self.role, self.request.id
47        );
48        request
49    }
50}
51
52/// The role and that role's own elicitation id behind an id made by
53/// [`ReviewerQuestion::session_request`], or `None` for any other id.
54#[must_use]
55pub fn parse_reviewer_question_id(id: &str) -> Option<(&str, &str)> {
56    id.strip_prefix(REVIEWER_QUESTION_PREFIX)?
57        .split_once('.')
58        .filter(|(role, inner)| !role.is_empty() && !inner.is_empty())
59}
60
61impl RuntimeReviewView {
62    /// Whether progress indicators should move. A verdict and a failed
63    /// handoff wait for the user even though they retain an activity label.
64    #[must_use]
65    pub fn is_working(&self) -> bool {
66        // A reviewer waiting on a person's answer makes no progress on its own.
67        self.questions.is_empty()
68            && matches!(
69                self.phase,
70                TurnReviewPhase::CapturingDelta
71                    | TurnReviewPhase::LaunchingReviewer
72                    | TurnReviewPhase::Running { .. }
73                    | TurnReviewPhase::Forwarding { error: None, .. }
74            )
75    }
76
77    /// A compact activity label for session lists and headers. Read typed
78    /// state rather than matching the driver's human-facing progress text.
79    #[must_use]
80    pub fn activity_label(&self) -> Option<&'static str> {
81        match &self.phase {
82            TurnReviewPhase::Resolved(_) => None,
83            _ if !self.questions.is_empty() => Some("Question"),
84            TurnReviewPhase::Forwarding { error: None, .. } => Some("Sending findings"),
85            TurnReviewPhase::Forwarding { error: Some(_), .. } => Some("Forward failed"),
86            TurnReviewPhase::Verdict(verdict) => Some(match verdict {
87                ReviewVerdict::Findings { .. } => "Findings",
88                ReviewVerdict::Failed { .. } => "Review failed",
89                ReviewVerdict::Clean => "Review complete",
90            }),
91            TurnReviewPhase::Running { roles }
92                if roles.iter().any(|role| {
93                    role.role == mj_core::review::driver::VALIDATOR_ROLE
94                        && matches!(role.state, RoleState::Pending | RoleState::Running)
95                }) =>
96            {
97                Some("Validating")
98            }
99            _ => Some("Reviewing"),
100        }
101    }
102}
103
104/// A verdict as a surface renders it.
105#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
106#[serde(deny_unknown_fields)]
107pub struct VerdictView {
108    pub kind: VerdictKind,
109    /// The findings, or the failure's reason. Empty for a clean verdict, which
110    /// resolves itself and is never on screen.
111    pub text: String,
112    /// Which resolutions this verdict accepts right now. A surface shows the
113    /// rest disabled rather than hiding them, so the buttons do not move.
114    pub allowed: Vec<Resolution>,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
118#[serde(rename_all = "snake_case")]
119pub enum VerdictKind {
120    Clean,
121    Findings,
122    Failed,
123}
124
125/// The relay session id one reviewing role journals under. The default role
126/// keeps the plan reviewer's id, which is the one the worker uses.
127#[must_use]
128pub fn role_session_id(primary_session_id: &str, role: &str) -> String {
129    if role == mj_core::review::driver::REVIEWER_ROLE {
130        format!("{primary_session_id}-reviewer")
131    } else {
132        format!("{primary_session_id}-review-{role}")
133    }
134}