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    fn resolve<'a>(
15        &'a self,
16        handle: ManagedSessionHandle,
17        config: ReviewConfig,
18        cancelled: Arc<std::sync::atomic::AtomicBool>,
19    ) -> mj_client::session::BoxFuture<
20        'a,
21        Result<mj_core::review::settings::ResolvedReviewSettings, String>,
22    >;
23
24    /// Stages the reviewer profile for one role and describes how to launch
25    /// it. Blocking: it copies a profile onto the session's target.
26    fn stage(
27        &self,
28        session_id: &str,
29        profile: &str,
30        generation: u64,
31        mcp_servers: &[mj_core::worker_launch::ReviewMcpServer],
32        dispatch_tool: bool,
33    ) -> Result<mj_core::worker_launch::ReviewerLaunchConfig, String>;
34
35    /// How far this session has been reviewed. Blocking: it reads the
36    /// controller's database.
37    fn load_state(&self, session_id: &str) -> Result<TurnReviewState, String>;
38
39    /// Records how far this session has been reviewed. Blocking: the host
40    /// routes it through its ordered persistence lane rather than calling it
41    /// on the Tokio task that owns review state.
42    fn save_state(&self, session_id: &str, state: &TurnReviewState) -> Result<(), String>;
43
44    /// Clears the in-flight flag of every review a restart interrupted, and
45    /// reports whose they were. Baselines are deliberately left alone: the
46    /// interrupted review never advanced one, so the next review covers the
47    /// same change and nothing is lost.
48    fn clear_interrupted(&self) -> Result<Vec<String>, String>;
49}
50
51/// The production environment: the controller as it is on disk right now.
52///
53/// It is reloaded per call rather than held, because a review is rare and the
54/// answer must reflect the config as it stands when the review starts -- the
55/// daemon reloads config.toml every 500 ms for the same reason.
56#[derive(Debug, Default)]
57pub struct ControllerEnvironment;
58
59impl ReviewEnvironment for ControllerEnvironment {
60    fn check(&self, session_id: &str, profile: &str) -> Result<(), String> {
61        let controller =
62            crate::controller::Controller::load().map_err(|error| format!("{error:#}"))?;
63        let Some(reviewer) = controller.config.profiles.get(profile) else {
64            return Err(format!(
65                "turn review needs a reviewer: [review] profile {profile:?} is not a profile in config.toml"
66            ));
67        };
68        if !reviewer.enabled {
69            return Err(format!(
70                "turn review needs an enabled reviewer: [review] profile {profile:?} is disabled"
71            ));
72        }
73        validate_reviewer_assignment(
74            session_id,
75            controller.state.sessions.get(session_id),
76            profile,
77        )
78    }
79
80    fn resolve<'a>(
81        &'a self,
82        handle: ManagedSessionHandle,
83        config: ReviewConfig,
84        cancelled: Arc<std::sync::atomic::AtomicBool>,
85    ) -> mj_client::session::BoxFuture<
86        'a,
87        Result<mj_core::review::settings::ResolvedReviewSettings, String>,
88    > {
89        Box::pin(async move {
90            let specialists = config.tier == ReviewTier::Extended;
91            crate::review_selection::resolve(handle, Some(config), specialists, cancelled)
92                .await
93                .map_err(|e| format!("{e:#}"))
94        })
95    }
96
97    fn stage(
98        &self,
99        session_id: &str,
100        profile: &str,
101        generation: u64,
102        mcp_servers: &[mj_core::worker_launch::ReviewMcpServer],
103        dispatch_tool: bool,
104    ) -> Result<mj_core::worker_launch::ReviewerLaunchConfig, String> {
105        let controller =
106            crate::controller::Controller::load().map_err(|error| format!("{error:#}"))?;
107        controller
108            .stage_reviewer_profile_with_mcp(
109                session_id,
110                profile,
111                generation,
112                mcp_servers,
113                dispatch_tool,
114            )
115            .map_err(|error| format!("{error:#}"))
116    }
117
118    fn load_state(&self, session_id: &str) -> Result<TurnReviewState, String> {
119        crate::database::turn_review_state(session_id).map_err(|error| format!("{error:#}"))
120    }
121
122    fn save_state(&self, session_id: &str, state: &TurnReviewState) -> Result<(), String> {
123        crate::database::save_turn_review_state(session_id, state)
124            .map_err(|error| format!("{error:#}"))
125    }
126
127    fn clear_interrupted(&self) -> Result<Vec<String>, String> {
128        crate::database::clear_interrupted_turn_reviews().map_err(|error| format!("{error:#}"))
129    }
130}
131
132pub(crate) fn validate_reviewer_assignment(
133    session_id: &str,
134    session: Option<&mj_core::state::SessionRecord>,
135    _profile: &str,
136) -> Result<(), String> {
137    let Some(session) = session else {
138        return Err(format!(
139            "session {session_id:?} is not in the controller store"
140        ));
141    };
142    if session.archived {
143        return Err("this session is archived".to_owned());
144    }
145    Ok(())
146}