Skip to main content

mj_controller/server/api/
types.rs

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