Skip to main content

mj_controller/server/api/
types.rs

1use super::*;
2
3/// Observed provider-owned background work; absent when no live snapshot is available.
4#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
5pub struct ApiBackgroundWork {
6    pub known: Option<bool>,
7    pub tasks: Vec<mj_core::relay::BackgroundCommand>,
8}
9
10impl From<&mj_core::relay::RelayOperationalState> for ApiBackgroundWork {
11    fn from(state: &mj_core::relay::RelayOperationalState) -> Self {
12        Self {
13            known: state.background_work_known,
14            tasks: state.background_commands.clone(),
15        }
16    }
17}
18
19/// One session as the API presents it. This is a narrower, more stable shape
20/// than the viewer's own session projection, which changes whenever the browser
21/// needs something new.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct ApiSession {
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub background_work: Option<ApiBackgroundWork>,
26    pub id: String,
27    pub workspace_id: String,
28    pub title: String,
29    pub harness_kind: String,
30    pub profile_id: String,
31    pub target_id: String,
32    pub bundle_id: String,
33    pub state: String,
34    pub lifecycle: ViewerLifecycleCategory,
35    pub chat_phase: crate::server::ViewerChatPhase,
36    pub is_idle: bool,
37    pub has_error: bool,
38    /// Why a launch failed, for a session in the error state. Absent otherwise.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub error: Option<String>,
41    pub created_at: String,
42    pub updated_at: String,
43    /// How the last finished prompt ended. Absent unless the caller asked for
44    /// one session by id or waited on it, because the dashboard projection the
45    /// list is built from does not carry turn identity.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub last_turn_outcome: Option<MaterializedTurnOutcome>,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub last_turn_diagnostic: Option<mj_core::diagnostic::TurnDiagnostic>,
50    #[serde(default)]
51    pub config_options: Vec<crate::server::ViewerConfigOption>,
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub pending_elicitations: Vec<mj_core::elicitation::ElicitationRequest>,
54}
55
56impl From<&ViewerSession> for ApiSession {
57    fn from(session: &ViewerSession) -> Self {
58        Self {
59            background_work: None,
60            id: session.id.clone(),
61            workspace_id: session.workspace_id.clone(),
62            title: session.title.clone(),
63            harness_kind: session.harness_kind.clone(),
64            profile_id: session.profile_id.clone(),
65            target_id: session.target_id.clone(),
66            bundle_id: session.bundle_id.clone(),
67            state: session.state.clone(),
68            lifecycle: session.lifecycle,
69            chat_phase: session.chat_phase,
70            is_idle: session.is_idle,
71            has_error: session.has_error,
72            error: session.launch_error.clone(),
73            created_at: session.created_at.clone(),
74            updated_at: session.updated_at.clone(),
75            last_turn_outcome: None,
76            last_turn_diagnostic: None,
77            config_options: session.config_options.clone(),
78            pending_elicitations: session.pending_elicitations.clone(),
79        }
80    }
81}
82
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84pub struct SessionListResponse {
85    pub sessions: Vec<ApiSession>,
86}
87
88/// Create a session and, optionally, send its first prompt. Served in M2.
89#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
90#[serde(deny_unknown_fields)]
91pub struct StartSessionRequest {
92    #[serde(default)]
93    pub create_managed_worktree: Option<bool>,
94    /// None follows the global `[subagents] enabled` setting.
95    #[serde(default)]
96    pub mjolnir_subagents: Option<bool>,
97    #[serde(default)]
98    pub workspace_id: Option<String>,
99    pub profile_id: String,
100    pub target_id: String,
101    #[serde(default)]
102    pub bundle_id: Option<String>,
103    #[serde(default)]
104    pub project_directory: Option<PathBuf>,
105    #[serde(default)]
106    pub title: Option<String>,
107    #[serde(default)]
108    pub model: Option<String>,
109    #[serde(default)]
110    pub effort: Option<String>,
111    #[serde(default)]
112    pub prompt: Option<String>,
113}
114
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116pub struct StartSessionResponse {
117    pub session_id: String,
118    /// The turn the follow-up prompt was accepted as, once it has been
119    /// submitted. Creation answers before that, so it is usually absent.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub turn_id: Option<u64>,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct SubagentSourceRange {
127    pub file: PathBuf,
128    pub start: u64,
129    pub end: u64,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(deny_unknown_fields)]
134pub struct SpawnSubagentRequest {
135    pub task_name: String,
136    pub instructions: String,
137    #[serde(default)]
138    pub profile_id: Option<String>,
139    #[serde(default)]
140    pub model: Option<String>,
141    #[serde(default)]
142    pub effort: Option<String>,
143    #[serde(default)]
144    pub working_directory: Option<PathBuf>,
145    #[serde(default)]
146    pub context: Option<String>,
147    #[serde(default)]
148    pub files: Vec<SubagentSourceRange>,
149    pub request_key: String,
150}
151
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
153pub struct SubagentView {
154    pub parent_session_id: String,
155    pub task_name: String,
156    pub request_key: String,
157    pub session: ApiSession,
158}
159
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161pub struct SubagentListResponse {
162    pub subagents: Vec<SubagentView>,
163}
164
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166#[serde(deny_unknown_fields)]
167pub struct PromptRequest {
168    pub text: String,
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172pub struct PromptResponse {
173    /// The relay acceptance ordinal for this prompt, which is what `wait`
174    /// takes as `turn_id`.
175    pub turn_id: u64,
176}
177
178#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
179#[serde(deny_unknown_fields)]
180pub struct WaitRequest {
181    /// Return when the harness presents a structured input request.
182    #[serde(default)]
183    pub return_on_input: bool,
184    /// Wait for this specific prompt. Absent means "wait until the session is
185    /// idle with nothing queued", which is what a caller that lost its turn id
186    /// wants.
187    #[serde(default)]
188    pub turn_id: Option<u64>,
189    #[serde(default)]
190    pub timeout_secs: Option<u64>,
191}
192
193/// How a wait ended.
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "snake_case")]
196pub enum WaitOutcome {
197    /// A structured elicitation needs an answer; only returned by opt-in waits.
198    InputRequired,
199    /// The turn completed normally.
200    Finished,
201    /// The turn failed, was rejected, or the session reported an error.
202    Error,
203    /// The turn was cancelled or interrupted.
204    Cancelled,
205    /// The model was at capacity and no retry is armed.
206    QuotaLimit,
207    /// The wait's deadline passed with the turn still running.
208    Timeout,
209    /// The session stopped or is stopping, so no turn can finish on it.
210    Stopped,
211}
212
213#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
214pub struct WaitCapacityRetry {
215    pub attempt: u32,
216    pub retry_at_ms: i64,
217}
218
219impl From<&CapacityRetry> for WaitCapacityRetry {
220    fn from(retry: &CapacityRetry) -> Self {
221        Self {
222            attempt: retry.attempt,
223            retry_at_ms: retry.retry_at_ms,
224        }
225    }
226}
227
228/// How the daemon's live view of a session's relay is doing.
229///
230/// This reports; it never decides an outcome. A caller that gets `timeout`
231/// needs to tell "the turn is still working" from "the daemon cannot see the
232/// worker at all", and those look identical without it.
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "snake_case")]
235pub enum RelayState {
236    /// The daemon is attached to the worker and following its events.
237    Connected,
238    /// Not attached, with no error recorded yet: attaching, or between tries.
239    Disconnected,
240    /// The worker could not be reached.
241    Unreachable,
242    /// The session's target is gone.
243    TargetMissing,
244    /// The event stream did not line up with what the daemon had projected.
245    ProjectionIntegrity,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249pub struct RelayHealth {
250    pub state: RelayState,
251    /// The view's own description of the problem, when it recorded one.
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub detail: Option<String>,
254}
255
256impl From<&mj_client::session::ManagedSessionView> for RelayHealth {
257    fn from(view: &mj_client::session::ManagedSessionView) -> Self {
258        use mj_client::session::ViewError;
259        // A recorded error outranks `connected`: it is the specific thing
260        // standing between the caller and a finished turn.
261        match &view.error {
262            Some(error) => Self {
263                state: match error {
264                    ViewError::Unreachable(_) => RelayState::Unreachable,
265                    ViewError::TargetMissing(_) => RelayState::TargetMissing,
266                    ViewError::ProjectionIntegrity(_) => RelayState::ProjectionIntegrity,
267                },
268                detail: Some(error.detail().to_owned()),
269            },
270            None if view.connected => Self {
271                state: RelayState::Connected,
272                detail: None,
273            },
274            None => Self {
275                state: RelayState::Disconnected,
276                detail: None,
277            },
278        }
279    }
280}
281
282#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
283pub struct WaitResponse {
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub diagnostic: Option<mj_core::diagnostic::TurnDiagnostic>,
286
287    #[serde(default, skip_serializing_if = "Vec::is_empty")]
288    pub pending_elicitations: Vec<mj_core::elicitation::ElicitationRequest>,
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub usage: Option<mj_core::usage::TokenUsage>,
291    pub outcome: WaitOutcome,
292    /// The harness's own stop reason, when the turn reached one.
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub stop_reason: Option<String>,
295    /// Why the wait ended this way, when there is something to say.
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub message: Option<String>,
298    /// The agent's last message of the turn, flattened to text.
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub final_message: Option<String>,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub turn_id: Option<u64>,
303    /// One-based position of this turn in the conversation.
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub turn_number: Option<u64>,
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub elapsed_ms: Option<i64>,
308    /// A capacity retry the worker has armed. While one is pending the caller
309    /// must not submit its own prompt: it would collide with the retry.
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub capacity_retry: Option<WaitCapacityRetry>,
312    /// The health of the daemon's live view of this session. Absent when no
313    /// live actor holds the session, because there is then no view to report
314    /// on and inventing one would be worse than saying nothing.
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub relay: Option<RelayHealth>,
317    pub session: ApiSession,
318}
319
320/// How many transcript items a page carries when the caller names no limit,
321/// and the most it may ask for. A caller that asks for more gets the ceiling
322/// rather than an error: paging is the point, and refusing a large limit would
323/// only make the caller retry with a smaller one.
324pub const DEFAULT_TRANSCRIPT_LIMIT: usize = 200;
325pub const MAX_TRANSCRIPT_LIMIT: usize = 1_000;
326
327#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
328pub struct TranscriptQuery {
329    #[serde(default)]
330    pub role: Option<mj_core::transcript::TranscriptRole>,
331    /// Resume from the highest sequence the caller has already seen.
332    #[serde(default)]
333    pub after_seq: Option<u64>,
334    #[serde(default)]
335    pub limit: Option<usize>,
336}
337
338#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
339pub struct TranscriptItemView {
340    pub stable_id: String,
341    pub position: u64,
342    /// What to pass as the next `after_seq`. It is the position for everything
343    /// but an agent message, which carries the ordinal of its latest content.
344    pub seq: u64,
345    pub role: String,
346    /// The item flattened to text, which is what a reading caller wants.
347    pub text: String,
348    pub created_at_ms: i64,
349    pub last_changed_at_ms: i64,
350    /// The stored body, for a caller that needs the structure behind the text.
351    pub body: mj_core::transcript::TranscriptBody,
352}
353
354#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
355pub struct TranscriptResponse {
356    #[serde(default)]
357    pub next_after_seq: u64,
358    pub session_id: String,
359    /// The newest sequence in the whole transcript. A page whose last item
360    /// reaches this is up to date.
361    pub latest_seq: u64,
362    pub execution: MaterializedExecutionState,
363    pub items: Vec<TranscriptItemView>,
364}
365
366// ---------------------------------------------------------------------------
367// Backend
368// ---------------------------------------------------------------------------
369
370/// Where a session stands turn by turn, read from the durable projection when
371/// no live actor holds the session.
372#[derive(Debug, Clone, PartialEq, Eq)]
373pub struct TurnState {
374    pub execution: MaterializedExecutionState,
375    pub active_turn: Option<MaterializedTurn>,
376    pub last_turn_outcome: Option<MaterializedTurnOutcome>,
377}
378
379pub use crate::database::TurnSummary;
380
381/// Configuration and a first prompt to apply once a newly created session's
382/// harness is ready. Served in M2.
383#[derive(Debug, Clone, Default, PartialEq, Eq)]
384pub struct StartFollowup {
385    pub model: Option<String>,
386    pub effort: Option<String>,
387    pub prompt: Option<String>,
388}
389
390/// How far a created session's follow-up has got. Served in M2.
391#[derive(Debug, Clone, PartialEq, Eq)]
392pub enum StartStatus {
393    /// The session is still provisioning, or its harness is not ready.
394    Pending,
395    /// The follow-up prompt was submitted and accepted as this turn.
396    Submitted { turn_id: u64 },
397    /// The session could not be started, or the follow-up could not be applied.
398    Failed { message: String },
399}
400
401/// A page of transcript items, read from the durable projection.
402pub use crate::database::TranscriptPage;
403
404/// A branch the daemon pushed on the caller's behalf.
405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
406pub struct PushedBranch {
407    pub branch: String,
408    pub remote: String,
409}
410
411/// Which file of the session's workspace to read.
412#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
413pub struct FileQuery {
414    /// Path relative to the session's workspace root.
415    pub path: String,
416}
417
418/// What form the caller wants the session's work in.
419#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
420#[serde(rename_all = "snake_case")]
421pub enum ExportKind {
422    /// A unified diff, as `GET /diff` returns.
423    Patch,
424    /// A branch pushed to the repository's push remote.
425    Branch,
426    /// The git bundle of the session's committed work.
427    Bundle,
428}
429
430#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
431pub struct ExportRequest {
432    pub kind: ExportKind,
433    /// The branch to push. Required when `kind` is `branch`.
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub branch: Option<String>,
436}
437
438/// A git bundle of the session's work.
439#[derive(Debug, Clone)]
440pub struct BundleExport {
441    pub repository: String,
442    pub bytes: Vec<u8>,
443}
444
445/// Why an export could not be produced. Served in M4.
446#[derive(Debug)]
447pub enum ExportError {
448    /// The session is in a state where this export is not possible. The caller
449    /// can act on it, so it answers 409.
450    Refused(String),
451    /// The export was attempted and failed.
452    Failed(anyhow::Error),
453}
454
455impl From<ExportError> for ApiFailure {
456    fn from(error: ExportError) -> Self {
457        match error {
458            ExportError::Refused(message) => Self::conflict(message),
459            ExportError::Failed(error) => Self::from(error),
460        }
461    }
462}
463
464// ---------------------------------------------------------------------------
465// SessionWiki
466// ---------------------------------------------------------------------------
467
468#[derive(Debug, Default, Deserialize)]
469#[serde(deny_unknown_fields)]
470pub struct WikiSearchQuery {
471    #[serde(default)]
472    pub q: Option<String>,
473    #[serde(default)]
474    pub limit: Option<usize>,
475}
476
477#[derive(Debug, Default, Deserialize)]
478#[serde(deny_unknown_fields)]
479pub struct WikiBriefQuery {
480    #[serde(default)]
481    pub max_chars: Option<usize>,
482}
483
484#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
485pub struct WikiBriefResponse {
486    pub markdown: String,
487}
488
489/// The fields of a start request a restore needs. The archived session decides
490/// the rest: its title, and the project it ran in when the caller names none.
491#[derive(Debug, Clone, Default, Deserialize)]
492#[serde(deny_unknown_fields)]
493pub struct WikiRestoreBody {
494    #[serde(default)]
495    pub workspace_id: Option<String>,
496    pub profile_id: String,
497    pub target_id: String,
498    #[serde(default)]
499    pub project_directory: Option<PathBuf>,
500    #[serde(default)]
501    pub model: Option<String>,
502    #[serde(default)]
503    pub effort: Option<String>,
504}