Skip to main content

mj_controller/server/api/
subagent_backend.rs

1use super::*;
2
3/// Everything the API needs from the daemon: live session actors, the durable
4/// projection, and the target-side git operations.
5///
6/// The daemon's implementation lives in `server_runtime::api`; route tests
7/// supply a fake.
8pub trait SubagentBackend: Send + Sync {
9    fn events(
10        &self,
11        filter: crate::database::ApiEventFilter,
12        after_seq: Option<u64>,
13    ) -> BoxFuture<'_, AnyResult<crate::database::ApiEventPage>> {
14        events::load_events(filter, after_seq)
15    }
16
17    fn profile_config(
18        &self,
19        profile: String,
20        model: Option<String>,
21        refresh: bool,
22    ) -> BoxFuture<'_, AnyResult<mj_core::worker_launch::ProfileConfig>> {
23        Box::pin(crate::controller::profile_config::discover(
24            profile, model, refresh,
25        ))
26    }
27    /// What the daemon's background-warmed profile catalogue already holds for
28    /// a profile. It never launches a harness and never waits, so a caller a
29    /// model is blocked on can check a selector without paying for discovery.
30    /// `None` means the catalogue cannot answer yet, not that the profile is
31    /// unusable.
32    fn published_profile_config(
33        &self,
34        _profile: &str,
35    ) -> Option<mj_core::worker_launch::ProfileConfig> {
36        None
37    }
38    fn start_subagent(
39        &self,
40        _request: crate::controller::RegisterSubagentRequest,
41    ) -> BoxFuture<'_, AnyResult<mj_core::subagent::SubagentRecord>> {
42        Box::pin(async { anyhow::bail!("sub-agent creation is unavailable") })
43    }
44    fn list_subagents(
45        &self,
46        parent_session_id: String,
47    ) -> BoxFuture<'_, AnyResult<Vec<mj_core::subagent::SubagentRecord>>> {
48        Box::pin(async move {
49            tokio::task::spawn_blocking(move || crate::database::list_subagents(&parent_session_id))
50                .await?
51        })
52    }
53    fn read_context_file(
54        &self,
55        session_id: String,
56        path: PathBuf,
57    ) -> BoxFuture<'_, std::result::Result<Vec<u8>, ExportError>> {
58        self.read_file(session_id, path)
59    }
60    /// The workspaces the store holds, in the order the terminal's tabs and the
61    /// viewer's list show them.
62    fn list_workspaces(
63        &self,
64    ) -> BoxFuture<'_, AnyResult<Vec<mj_core::workspace::WorkspaceRecord>>> {
65        Box::pin(async { tokio::task::spawn_blocking(crate::database::list_workspaces).await? })
66    }
67    /// The workspace with this name, creating it when the store holds none.
68    ///
69    /// This is the daemon's own `CreateWorkspace` operation: create-or-get, so
70    /// two callers that both saw an empty list attach to the same normalized
71    /// name instead of one of them meeting a SQLite conflict. The daemon
72    /// overrides it to republish the list afterwards.
73    fn create_workspace(
74        &self,
75        name: String,
76    ) -> BoxFuture<'_, AnyResult<mj_core::workspace::WorkspaceRecord>> {
77        Box::pin(async move {
78            tokio::task::spawn_blocking(move || crate::database::create_or_get_workspace(&name))
79                .await?
80        })
81    }
82    fn set_config(
83        &self,
84        session_id: String,
85        key: String,
86        value: String,
87    ) -> BoxFuture<'_, AnyResult<()>> {
88        Box::pin(async move {
89            self.session_handle(session_id)
90                .await?
91                .ok_or_else(|| anyhow::anyhow!("session has no live actor"))?
92                .set_config(key, value)
93                .await
94        })
95    }
96    fn cancel_start(&self, _session_id: String) -> BoxFuture<'_, AnyResult<()>> {
97        Box::pin(async { Ok(()) })
98    }
99    /// The live actor for a session, or `None` when none holds it.
100    fn session_handle(&self, session_id: String)
101    -> BoxFuture<'_, AnyResult<Option<SessionHandle>>>;
102
103    /// Submit a prompt, returning its relay acceptance ordinal.
104    fn prompt(&self, session_id: String, text: String) -> BoxFuture<'_, AnyResult<u64>>;
105
106    /// Durable turn state for a session with no live actor.
107    fn turn_state(&self, session_id: String) -> BoxFuture<'_, AnyResult<Option<TurnState>>>;
108
109    /// Summarize the turn that covers these transcript positions.
110    fn turn_summary(
111        &self,
112        session_id: String,
113        turn: TurnSpan,
114    ) -> BoxFuture<'_, AnyResult<TurnSummary>>;
115
116    /// Apply model, effort, and the first prompt once a new session is ready.
117    fn start_followup(
118        &self,
119        session_id: String,
120        followup: StartFollowup,
121    ) -> BoxFuture<'_, AnyResult<()>>;
122
123    /// How far a created session's follow-up has got.
124    fn start_status(&self, session_id: String) -> BoxFuture<'_, AnyResult<Option<StartStatus>>>;
125
126    /// A page of transcript items after `after_seq`.
127    fn transcript(
128        &self,
129        session_id: String,
130        after_seq: u64,
131        limit: usize,
132        role: Option<mj_core::transcript::TranscriptRole>,
133    ) -> BoxFuture<'_, AnyResult<Option<TranscriptPage>>>;
134
135    fn usage(
136        &self,
137        session_id: String,
138        after_seq: u64,
139        limit: usize,
140    ) -> BoxFuture<'_, AnyResult<Option<crate::database::UsagePage>>> {
141        Box::pin(async move {
142            tokio::task::spawn_blocking(move || {
143                crate::database::load_session_usage(&session_id, after_seq, limit)
144            })
145            .await?
146        })
147    }
148
149    /// A unified diff of the session's work.
150    fn diff(&self, session_id: String) -> BoxFuture<'_, Result<String, ExportError>>;
151
152    /// One file from the session's workspace.
153    fn read_file(
154        &self,
155        session_id: String,
156        path: PathBuf,
157    ) -> BoxFuture<'_, Result<Vec<u8>, ExportError>>;
158
159    fn write_file(
160        &self,
161        _session_id: String,
162        _path: PathBuf,
163        _bytes: Vec<u8>,
164        _overwrite: bool,
165    ) -> BoxFuture<'_, Result<(), ExportError>> {
166        Box::pin(async { Err(ExportError::Refused("file injection is unavailable".into())) })
167    }
168
169    /// Push the session's branch to its repository's default remote.
170    fn push_branch(
171        &self,
172        session_id: String,
173        branch: String,
174    ) -> BoxFuture<'_, Result<PushedBranch, ExportError>>;
175
176    /// A git bundle of the session's committed work.
177    fn bundle(&self, session_id: String) -> BoxFuture<'_, Result<BundleExport, ExportError>>;
178
179    /// Whether the index was synced recently enough that a query need not ask
180    /// for one.
181    fn wiki_sync_is_stale(&self) -> bool {
182        false
183    }
184
185    /// Ask for a background sync. It is never waited for: a query answers from
186    /// what the index holds now.
187    fn wiki_request_sync(&self) {}
188
189    fn wiki_search(
190        &self,
191        _query: String,
192        _limit: usize,
193    ) -> BoxFuture<'_, AnyResult<mj_client::daemon::WikiSearchPage>> {
194        Box::pin(async { anyhow::bail!("SessionWiki search is unavailable") })
195    }
196
197    /// `None` when the index holds no session with that id.
198    fn wiki_brief(
199        &self,
200        _wiki_id: String,
201        _max_chars: usize,
202    ) -> BoxFuture<'_, AnyResult<Option<String>>> {
203        Box::pin(async { anyhow::bail!("SessionWiki briefings are unavailable") })
204    }
205
206    /// The passages of one indexed session that match a query. `None` when the
207    /// index holds no session with that id.
208    fn wiki_hits(
209        &self,
210        _wiki_id: String,
211        _query: String,
212        _context_messages: usize,
213        _per_message_chars: usize,
214    ) -> BoxFuture<'_, AnyResult<Option<mj_client::daemon::WikiHitTranscript>>> {
215        Box::pin(async { anyhow::bail!("SessionWiki transcript hits are unavailable") })
216    }
217
218    /// Start a session from an archived transcript, answering with its id, or
219    /// `None` when the index holds no session with that id.
220    fn wiki_restore(
221        &self,
222        _request: mj_client::daemon::WikiRestoreRequest,
223    ) -> BoxFuture<'_, AnyResult<Option<String>>> {
224        Box::pin(async { anyhow::bail!("SessionWiki restore is unavailable") })
225    }
226}
227
228pub(super) fn backend(state: &ServerState) -> Result<&Arc<dyn SubagentBackend>, ApiFailure> {
229    state
230        .subagent
231        .as_ref()
232        .ok_or_else(|| ApiFailure::unavailable("this server has no subagent backend installed"))
233}
234
235// ---------------------------------------------------------------------------
236// Wait resolution
237// ---------------------------------------------------------------------------