Skip to main content

mj_client/
review.rs

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