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