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 runtime_receipt(
10        &self,
11        _session_id: String,
12    ) -> BoxFuture<'_, AnyResult<Option<mj_core::harness_runtime::RuntimeReceipt>>> {
13        Box::pin(async { Ok(None) })
14    }
15
16    fn transcript_history(
17        &self,
18        session_id: String,
19        before: Option<mj_core::storage::TranscriptCursor>,
20    ) -> BoxFuture<'_, AnyResult<mj_core::storage::TranscriptHistoryPage>> {
21        Box::pin(async move {
22            tokio::task::spawn_blocking(move || {
23                crate::database::load_transcript_history(&session_id, before.as_ref(), 128)?
24                    .context("session history is unavailable")
25            })
26            .await?
27        })
28    }
29
30    fn native_agent_history(
31        &self,
32        owner: String,
33        child: String,
34        before: Option<(u64, String)>,
35    ) -> BoxFuture<'_, AnyResult<mj_core::native_agent::NativeAgentHistoryPage>> {
36        Box::pin(async move {
37            tokio::task::spawn_blocking(move || {
38                crate::database::native_agent_history(&owner, &child, before)
39            })
40            .await?
41        })
42    }
43    fn events(
44        &self,
45        filter: crate::database::ApiEventFilter,
46        after_seq: Option<u64>,
47    ) -> BoxFuture<'_, AnyResult<crate::database::ApiEventPage>> {
48        events::load_events(filter, after_seq)
49    }
50
51    fn profile_config(
52        &self,
53        profile: String,
54        model: Option<String>,
55        refresh: bool,
56    ) -> BoxFuture<'_, AnyResult<mj_core::worker_launch::ProfileConfig>> {
57        Box::pin(crate::controller::profile_config::discover(
58            profile, model, refresh,
59        ))
60    }
61    /// The profiles a parent running on `parent_profile` may start a sub-agent
62    /// on, each with its discovered choices and remaining quota. It waits for
63    /// a discovery the daemon has not finished; a profile whose discovery
64    /// fails is reported as unavailable rather than failing the whole answer.
65    fn subagent_candidates(
66        &self,
67        _parent_profile: String,
68    ) -> BoxFuture<'_, AnyResult<SubagentCandidates>> {
69        Box::pin(async { anyhow::bail!("sub-agent profiles are unavailable") })
70    }
71    fn start_subagent(
72        &self,
73        _request: crate::controller::RegisterSubagentRequest,
74    ) -> BoxFuture<'_, AnyResult<mj_core::subagent::SubagentRecord>> {
75        Box::pin(async { anyhow::bail!("sub-agent creation is unavailable") })
76    }
77    fn list_subagents(
78        &self,
79        parent_session_id: String,
80    ) -> BoxFuture<'_, AnyResult<Vec<mj_core::subagent::SubagentRecord>>> {
81        Box::pin(async move {
82            tokio::task::spawn_blocking(move || crate::database::list_subagents(&parent_session_id))
83                .await?
84        })
85    }
86    /// Whether a sub-agent child has handed back its report for its parent's
87    /// newest task; see [`crate::controller::subagent_has_handed_back`].
88    fn subagent_handed_back(&self, child_session_id: String) -> BoxFuture<'_, AnyResult<bool>> {
89        Box::pin(async move {
90            tokio::task::spawn_blocking(move || {
91                crate::controller::subagent_has_handed_back(&child_session_id)
92            })
93            .await?
94        })
95    }
96    fn read_context_file(
97        &self,
98        session_id: String,
99        path: PathBuf,
100    ) -> BoxFuture<'_, std::result::Result<Vec<u8>, ExportError>> {
101        self.read_file(session_id, path)
102    }
103    /// The workspaces the store holds, in the order the terminal's tabs and the
104    /// viewer's list show them.
105    fn list_workspaces(
106        &self,
107    ) -> BoxFuture<'_, AnyResult<Vec<mj_core::workspace::WorkspaceRecord>>> {
108        Box::pin(async { tokio::task::spawn_blocking(crate::database::list_workspaces).await? })
109    }
110    /// The workspace with this name, creating it when the store holds none.
111    ///
112    /// This is the daemon's own `CreateWorkspace` operation: create-or-get, so
113    /// two callers that both saw an empty list attach to the same normalized
114    /// name instead of one of them meeting a SQLite conflict. The daemon
115    /// overrides it to republish the list afterwards.
116    fn create_workspace(
117        &self,
118        name: String,
119    ) -> BoxFuture<'_, AnyResult<mj_core::workspace::WorkspaceRecord>> {
120        Box::pin(async move {
121            tokio::task::spawn_blocking(move || crate::database::create_or_get_workspace(&name))
122                .await?
123        })
124    }
125    fn set_config(
126        &self,
127        session_id: String,
128        key: String,
129        value: String,
130    ) -> BoxFuture<'_, AnyResult<()>> {
131        Box::pin(async move {
132            self.session_handle(session_id)
133                .await?
134                .ok_or_else(|| anyhow::anyhow!("session has no live actor"))?
135                .set_config(key, value)
136                .await
137        })
138    }
139    fn cancel_start(&self, _session_id: String) -> BoxFuture<'_, AnyResult<()>> {
140        Box::pin(async { Ok(()) })
141    }
142    /// The live actor for a session, or `None` when none holds it.
143    fn session_handle(&self, session_id: String)
144    -> BoxFuture<'_, AnyResult<Option<SessionHandle>>>;
145
146    /// Submit a prompt, returning its relay acceptance ordinal.
147    fn prompt(&self, session_id: String, text: String) -> BoxFuture<'_, AnyResult<u64>>;
148
149    /// Durable turn state for a session with no live actor.
150    fn turn_state(&self, session_id: String) -> BoxFuture<'_, AnyResult<Option<TurnState>>>;
151
152    /// A sub-agent child's recorded report, and whether it has the handback
153    /// tool. `None` for a session that is not a Mjolnir sub-agent.
154    fn subagent_report(
155        &self,
156        _session_id: String,
157    ) -> BoxFuture<'_, AnyResult<Option<(bool, mj_core::subagent::SubagentReport)>>> {
158        Box::pin(async { Ok(None) })
159    }
160
161    /// Summarize the turn that covers these transcript positions.
162    fn turn_summary(
163        &self,
164        session_id: String,
165        turn: TurnSpan,
166    ) -> BoxFuture<'_, AnyResult<TurnSummary>>;
167
168    /// Apply model, effort, and the first prompt once a new session is ready.
169    fn start_followup(
170        &self,
171        session_id: String,
172        followup: StartFollowup,
173    ) -> BoxFuture<'_, AnyResult<()>>;
174
175    /// How far a created session's follow-up has got.
176    fn start_status(&self, session_id: String) -> BoxFuture<'_, AnyResult<Option<StartStatus>>>;
177
178    /// A page of transcript items after `after_seq`.
179    fn transcript(
180        &self,
181        session_id: String,
182        after_seq: u64,
183        limit: usize,
184        role: Option<mj_core::transcript::TranscriptRole>,
185    ) -> BoxFuture<'_, AnyResult<Option<TranscriptPage>>>;
186
187    fn usage(
188        &self,
189        session_id: String,
190        after_seq: u64,
191        limit: usize,
192    ) -> BoxFuture<'_, AnyResult<Option<crate::database::UsagePage>>> {
193        Box::pin(async move {
194            tokio::task::spawn_blocking(move || {
195                crate::database::load_session_usage(&session_id, after_seq, limit)
196            })
197            .await?
198        })
199    }
200
201    /// A unified diff of the session's work.
202    fn diff(
203        &self,
204        session_id: String,
205        options: DiffOptions,
206    ) -> BoxFuture<'_, Result<String, ExportError>>;
207
208    /// One file from the session's workspace.
209    fn read_file(
210        &self,
211        session_id: String,
212        path: PathBuf,
213    ) -> BoxFuture<'_, Result<Vec<u8>, ExportError>>;
214
215    fn write_file(
216        &self,
217        _session_id: String,
218        _path: PathBuf,
219        _bytes: Vec<u8>,
220        _overwrite: bool,
221    ) -> BoxFuture<'_, Result<(), ExportError>> {
222        Box::pin(async { Err(ExportError::Refused("file injection is unavailable".into())) })
223    }
224
225    /// Push the session's branch to its repository's default remote.
226    fn push_branch(
227        &self,
228        session_id: String,
229        branch: String,
230    ) -> BoxFuture<'_, Result<PushedBranch, ExportError>>;
231
232    /// A git bundle of the session's committed work.
233    fn bundle(&self, session_id: String) -> BoxFuture<'_, Result<BundleExport, ExportError>>;
234
235    /// Whether the index was synced recently enough that a query need not ask
236    /// for one.
237    fn wiki_sync_is_stale(&self) -> bool {
238        false
239    }
240
241    /// Ask for a background sync. It is never waited for: a query answers from
242    /// what the index holds now.
243    fn wiki_request_sync(&self) {}
244
245    fn wiki_search(
246        &self,
247        _query: String,
248        _limit: usize,
249    ) -> BoxFuture<'_, AnyResult<mj_client::daemon::WikiSearchPage>> {
250        Box::pin(async { anyhow::bail!("SessionWiki search is unavailable") })
251    }
252
253    /// `None` when the index holds no session with that id.
254    fn wiki_brief(
255        &self,
256        _wiki_id: String,
257        _max_chars: usize,
258    ) -> BoxFuture<'_, AnyResult<Option<String>>> {
259        Box::pin(async { anyhow::bail!("SessionWiki briefings are unavailable") })
260    }
261
262    /// The passages of one indexed session that match a query. `None` when the
263    /// index holds no session with that id.
264    fn wiki_hits(
265        &self,
266        _wiki_id: String,
267        _query: String,
268        _context_messages: usize,
269        _per_message_chars: usize,
270    ) -> BoxFuture<'_, AnyResult<Option<mj_client::daemon::WikiHitTranscript>>> {
271        Box::pin(async { anyhow::bail!("SessionWiki transcript hits are unavailable") })
272    }
273
274    /// What one indexed session is, and what continuing it would mean.
275    /// `None` when the index holds no session with that id.
276    fn wiki_session(
277        &self,
278        _wiki_id: String,
279    ) -> BoxFuture<'_, AnyResult<Option<mj_client::daemon::WikiSessionInfo>>> {
280        Box::pin(async { anyhow::bail!("SessionWiki lookups are unavailable") })
281    }
282
283    /// Start a session from an archived transcript, answering with its id, or
284    /// `None` when the index holds no session with that id.
285    fn wiki_restore(
286        &self,
287        _request: mj_client::daemon::WikiRestoreRequest,
288    ) -> BoxFuture<'_, AnyResult<Option<String>>> {
289        Box::pin(async { anyhow::bail!("SessionWiki restore is unavailable") })
290    }
291}
292
293pub(super) fn backend(state: &ServerState) -> Result<&Arc<dyn SubagentBackend>, ApiFailure> {
294    state
295        .subagent
296        .as_ref()
297        .ok_or_else(|| ApiFailure::unavailable("this server has no subagent backend installed"))
298}
299
300// ---------------------------------------------------------------------------
301// Wait resolution
302// ---------------------------------------------------------------------------