Skip to main content

mj_controller/review_host/
environment.rs

1use super::*;
2
3/// Everything a review needs from the controller: whether it can review this
4/// session at all, and a staged reviewer profile to launch a role from.
5///
6/// It is a trait so the host's own tests can drive a whole review without a
7/// container, a harness, or the developer's own `config.toml`. The daemon
8/// installs [`ControllerEnvironment`], which loads the real controller.
9pub trait ReviewEnvironment: Send + Sync {
10    /// Refuses, with a sentence for a person, when this session cannot be
11    /// reviewed under `profile`.
12    fn check(&self, session_id: &str, profile: &str) -> Result<(), String>;
13
14    /// Stages the reviewer profile for one role and describes how to launch
15    /// it. Blocking: it copies a profile onto the session's target.
16    fn stage(
17        &self,
18        session_id: &str,
19        profile: &str,
20        generation: u64,
21        mcp_servers: &[mj_core::worker_launch::ReviewMcpServer],
22        dispatch_tool: bool,
23    ) -> Result<mj_core::worker_launch::ReviewerLaunchConfig, String>;
24
25    /// How far this session has been reviewed. Blocking: it reads the
26    /// controller's database.
27    fn load_state(&self, session_id: &str) -> Result<TurnReviewState, String>;
28
29    /// Records how far this session has been reviewed. Blocking: the host
30    /// routes it through its ordered persistence lane rather than calling it
31    /// on the Tokio task that owns review state.
32    fn save_state(&self, session_id: &str, state: &TurnReviewState) -> Result<(), String>;
33
34    /// Clears the in-flight flag of every review a restart interrupted, and
35    /// reports whose they were. Baselines are deliberately left alone: the
36    /// interrupted review never advanced one, so the next review covers the
37    /// same change and nothing is lost.
38    fn clear_interrupted(&self) -> Result<Vec<String>, String>;
39}
40
41/// The production environment: the controller as it is on disk right now.
42///
43/// It is reloaded per call rather than held, because a review is rare and the
44/// answer must reflect the config as it stands when the review starts -- the
45/// daemon reloads config.toml every 500 ms for the same reason.
46#[derive(Debug, Default)]
47pub struct ControllerEnvironment;
48
49impl ReviewEnvironment for ControllerEnvironment {
50    fn check(&self, session_id: &str, profile: &str) -> Result<(), String> {
51        let controller =
52            crate::controller::Controller::load().map_err(|error| format!("{error:#}"))?;
53        let Some(reviewer) = controller.config.profiles.get(profile) else {
54            return Err(format!(
55                "turn review needs a reviewer: [review] profile {profile:?} is not a profile in config.toml"
56            ));
57        };
58        if !reviewer.enabled {
59            return Err(format!(
60                "turn review needs an enabled reviewer: [review] profile {profile:?} is disabled"
61            ));
62        }
63        validate_reviewer_assignment(
64            session_id,
65            controller.state.sessions.get(session_id),
66            profile,
67        )
68    }
69
70    fn stage(
71        &self,
72        session_id: &str,
73        profile: &str,
74        generation: u64,
75        mcp_servers: &[mj_core::worker_launch::ReviewMcpServer],
76        dispatch_tool: bool,
77    ) -> Result<mj_core::worker_launch::ReviewerLaunchConfig, String> {
78        let controller =
79            crate::controller::Controller::load().map_err(|error| format!("{error:#}"))?;
80        controller
81            .stage_reviewer_profile_with_mcp(
82                session_id,
83                profile,
84                generation,
85                mcp_servers,
86                dispatch_tool,
87            )
88            .map_err(|error| format!("{error:#}"))
89    }
90
91    fn load_state(&self, session_id: &str) -> Result<TurnReviewState, String> {
92        crate::database::turn_review_state(session_id).map_err(|error| format!("{error:#}"))
93    }
94
95    fn save_state(&self, session_id: &str, state: &TurnReviewState) -> Result<(), String> {
96        crate::database::save_turn_review_state(session_id, state)
97            .map_err(|error| format!("{error:#}"))
98    }
99
100    fn clear_interrupted(&self) -> Result<Vec<String>, String> {
101        crate::database::clear_interrupted_turn_reviews().map_err(|error| format!("{error:#}"))
102    }
103}
104
105pub(crate) fn validate_reviewer_assignment(
106    session_id: &str,
107    session: Option<&mj_core::state::SessionRecord>,
108    profile: &str,
109) -> Result<(), String> {
110    let Some(session) = session else {
111        return Err(format!(
112            "session {session_id:?} is not in the controller store"
113        ));
114    };
115    if session.archived {
116        return Err("this session is archived".to_owned());
117    }
118    if session.last_profile == profile {
119        return Err(format!(
120            "turn review profile {profile:?} is also this session's primary profile; choose a different [review] profile"
121        ));
122    }
123    Ok(())
124}