Skip to main content

mj_controller/server/api/
types.rs

1use super::*;
2
3/// An externally produced item waiting in a session's durable mailbox.
4#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5#[serde(deny_unknown_fields)]
6pub struct MailboxEventRequest {
7    /// Idempotency key chosen by the producer.
8    pub key: String,
9    /// Untrusted event text shown to the session.
10    pub text: String,
11    #[serde(default)]
12    pub wake: bool,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct MailboxEventResponse {
18    pub key: String,
19    /// False when this exact event was already accepted into the outbox.
20    pub inserted: bool,
21}
22
23/// Result of delivering one message to a session, either through its mailbox
24/// or as a queued prompt for an older worker.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct SessionMessageResponse {
28    pub session_id: String,
29    pub via: String,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub turn_id: Option<u64>,
32    /// Internal routing detail used to preserve the shipped child-tool result
33    /// shape. It is not part of the HTTP response contract.
34    #[serde(skip)]
35    pub(crate) managed_child: bool,
36}
37
38/// A message sent by the CLI or another authenticated API client.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct SessionMessageRequest {
42    /// Stable identity across retries after an uncertain response.
43    pub request_id: String,
44    pub text: String,
45    /// Session asserted by the local caller, when this process runs inside a
46    /// session worker. The daemon verifies that it exists and is top-level.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub sender_session_id: Option<String>,
49}
50
51/// Query selecting the GitHub App installation whose token should be returned.
52#[derive(Debug, Default)]
53pub struct GithubTokenQuery {
54    pub owner: Option<String>,
55    pub repo: Vec<String>,
56}
57
58/// A currently valid installation access token. Callers must keep it out of
59/// logs and persistent session state.
60#[derive(Debug, Serialize, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct GithubTokenResponse {
63    pub token: String,
64}
65
66/// Comparison and representation requested for a session's working-tree diff.
67#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct DiffOptions {
70    pub base: Option<String>,
71    #[serde(default)]
72    pub json: bool,
73}
74
75/// Observed provider-owned background work; absent when no live snapshot is available.
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct ApiBackgroundWork {
78    pub known: Option<bool>,
79    pub tasks: Vec<mj_core::relay::BackgroundCommand>,
80}
81
82impl From<&mj_core::relay::RelayOperationalState> for ApiBackgroundWork {
83    fn from(state: &mj_core::relay::RelayOperationalState) -> Self {
84        Self {
85            known: state.background_work_known,
86            tasks: state.background_commands.clone(),
87        }
88    }
89}
90
91/// One session as the API presents it. This is a narrower, more stable shape
92/// than the viewer's own session projection, which changes whenever the browser
93/// needs something new.
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub struct ApiSession {
96    /// Tasks currently observed by the daemon, including their stop capability.
97    #[serde(default)]
98    pub background_tasks: Vec<crate::server::ViewerBackgroundTask>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub assessment: Option<mj_core::assessment::Summary>,
101    #[serde(default)]
102    pub subagents: mj_core::subagent::SubagentPolicy,
103    /// Immutable starting selection, named as `StartSessionRequest` names
104    /// it; session readiness verifies preparation.
105    /// Commit the workspace started checked out at, when one was named.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub at: Option<String>,
108    /// Branch created at `at`, or the existing branch checked out without it.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub branch: Option<String>,
111    /// Diff base the session was started with; `at` unless another was named.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub base: Option<String>,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub background_work: Option<ApiBackgroundWork>,
116    pub id: String,
117    pub workspace_id: String,
118    pub title: String,
119    pub harness_kind: String,
120    pub profile_id: String,
121    pub target_id: String,
122    pub bundle_id: String,
123    pub state: String,
124    pub lifecycle: ViewerLifecycleCategory,
125    pub chat_phase: crate::server::ViewerChatPhase,
126    pub is_idle: bool,
127    /// What this session is doing, in more detail than `chat_phase`'s four
128    /// values allow: in particular it can say that the daemon cannot see the
129    /// worker and report what it last knew, rather than claiming idleness it
130    /// cannot prove.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub activity_state: Option<mj_core::activity::ActivityState>,
133    pub has_error: bool,
134    /// A launch failure or a safe lifecycle failure, including a failed Move.
135    /// Raw runtime error text is deliberately not published for a running
136    /// session. Why a *turn* failed travels in `last_turn_diagnostic`,
137    /// which a single-session query fills.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub error: Option<String>,
140    /// Why this session's target cannot take writes: its disk is full.
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub storage_problem: Option<String>,
143    pub created_at: String,
144    pub updated_at: String,
145    /// How the last finished prompt ended. Absent unless the caller asked for
146    /// one session by id or waited on it, because the dashboard projection the
147    /// list is built from does not carry turn identity.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub last_turn_outcome: Option<MaterializedTurnOutcome>,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub last_turn_diagnostic: Option<mj_core::diagnostic::TurnDiagnostic>,
152    #[serde(default)]
153    pub config_options: Vec<crate::server::ViewerConfigOption>,
154    #[serde(default, skip_serializing_if = "Vec::is_empty")]
155    pub pending_elicitations: Vec<mj_core::elicitation::ElicitationRequest>,
156}
157
158impl From<&ViewerSession> for ApiSession {
159    fn from(session: &ViewerSession) -> Self {
160        Self {
161            background_tasks: session.background_tasks.clone(),
162            assessment: None,
163            subagents: session.subagents.clone(),
164            background_work: None,
165            at: session.at.clone(),
166            branch: session.branch.clone(),
167            base: session.base.clone(),
168            id: session.id.clone(),
169            workspace_id: session.workspace_id.clone(),
170            title: session.title.clone(),
171            harness_kind: session.harness_kind.clone(),
172            profile_id: session.profile_id.clone(),
173            target_id: session.target_id.clone(),
174            bundle_id: session.bundle_id.clone(),
175            state: session.state.clone(),
176            lifecycle: session.lifecycle,
177            chat_phase: session.chat_phase,
178            is_idle: session.is_idle,
179            activity_state: session.activity_state.clone(),
180            has_error: session.has_error,
181            error: session.launch_error.clone(),
182            storage_problem: session.storage_problem.clone(),
183            created_at: session.created_at.clone(),
184            updated_at: session.updated_at.clone(),
185            last_turn_outcome: None,
186            last_turn_diagnostic: None,
187            config_options: session.config_options.clone(),
188            pending_elicitations: session.pending_elicitations.clone(),
189        }
190    }
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
194#[serde(deny_unknown_fields)]
195pub struct StopBackgroundTaskRequest {
196    pub background_task_id: String,
197}
198
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
200pub struct SessionListResponse {
201    pub sessions: Vec<ApiSession>,
202}
203
204/// The workspaces the daemon holds, newest opening first, exactly as the
205/// terminal's workspace tabs and the viewer's list see them.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207pub struct WorkspaceListResponse {
208    pub workspaces: Vec<mj_core::workspace::WorkspaceRecord>,
209}
210
211/// Name the workspace to work in. The name is the identity: it is trimmed, at
212/// most 64 characters, and unique case-insensitively, so naming one that
213/// already exists returns it rather than making a second.
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(deny_unknown_fields)]
216pub struct CreateWorkspaceRequest {
217    pub name: String,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221pub struct CreateWorkspaceResponse {
222    pub workspace: mj_core::workspace::WorkspaceRecord,
223}
224
225/// Create a session and, optionally, send its first prompt. Served in M2.
226///
227/// `profile_id` and `target_id` may be omitted, and each resolves
228/// independently: a caller may name a profile and take the saved default
229/// target. When `model` is supplied without `profile_id`, the daemon considers
230/// all configured, usable profiles and ranks them by quota, using the saved
231/// default profile as the tie-breaking anchor. An omitted
232/// identifier otherwise comes from the pair the user last saved with the
233/// `mj go` workflow, which the first setup also becomes, so a caller that has
234/// never read `config.toml` can create a session by naming neither. The
235/// controller still receives two explicit identifiers, because a session
236/// whose profile was implicit would be a session nobody can explain later.
237#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
238#[serde(deny_unknown_fields)]
239pub struct StartSessionRequest {
240    #[serde(default)]
241    pub create_managed_worktree: Option<bool>,
242    /// Full commit object ID to start the workspace at: an exact checkout of
243    /// the bundle's primary repository, detached unless `branch` is given.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub at: Option<String>,
246    /// With `at`, the new branch created there; otherwise the existing branch
247    /// to check out in a new isolated workspace.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub branch: Option<String>,
250    /// Diff base, when it is not `at`. Without `at`, also the starting
251    /// revision for a raw managed worktree.
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub base: Option<String>,
254    /// Omitted uses the selected profile's subagent setting.
255    #[serde(default)]
256    pub subagents: Option<mj_core::subagent::SubagentPolicy>,
257    /// Turn review for this session. Omitted follows `[review]`.
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub review: Option<mj_core::config::SessionReview>,
260    /// Start the session with no project memory: nothing from the workspace's
261    /// project memory is injected and the harness is offered no memory
262    /// directory. Omitted or false keeps the workspace's project memory.
263    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
264    pub no_project_memory: bool,
265    /// Start the session with no agent mailbox: no GitHub watch events or
266    /// peer messages are routed to it and its harness gets no mailbox hook or
267    /// message tool. Only turns the session's mailbox off; it does not turn
268    /// mailboxes on when `[mailbox]` is off. Omitted or false follows
269    /// `[mailbox]`.
270    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
271    pub no_mailbox: bool,
272    #[serde(default)]
273    pub workspace_id: Option<String>,
274    /// Omitted follows the saved default, or anchors model-based profile
275    /// selection when `model` is supplied.
276    #[serde(default)]
277    pub profile_id: Option<String>,
278    /// Omitted follows the saved default. See the type's own documentation.
279    #[serde(default)]
280    pub target_id: Option<String>,
281    #[serde(default)]
282    pub bundle_id: Option<String>,
283    #[serde(default)]
284    pub project_directory: Option<PathBuf>,
285    #[serde(default)]
286    pub title: Option<String>,
287    #[serde(default)]
288    pub model: Option<String>,
289    #[serde(default)]
290    pub effort: Option<String>,
291    #[serde(default)]
292    pub prompt: Option<String>,
293    /// Container CPU limit. Omitted takes the target's default size, the
294    /// one the viewer's create form selects. Container targets only.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub cpus: Option<u64>,
297    /// Container memory limit in bytes, defaulted like `cpus`.
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub memory_bytes: Option<u64>,
300}
301
302/// Resume a stopped, lost, or failed session. Every field is optional: the
303/// session's own record supplies what the caller does not name, which is what
304/// makes `POST .../resume` with no body the scriptable "continue this session"
305/// call.
306#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
307#[serde(deny_unknown_fields)]
308pub struct ResumeSessionRequest {
309    /// Profile to resume on. Defaults to the one the session last ran.
310    #[serde(default)]
311    pub profile_id: Option<String>,
312    /// Target template to provision. Defaults to the session's own.
313    #[serde(default)]
314    pub target_id: Option<String>,
315    /// Workspace the resumed session belongs to. Defaults to its own.
316    #[serde(default)]
317    pub workspace_id: Option<String>,
318    /// Whether prompts queued when the session stopped are started or
319    /// discarded. Defaults to `start`, which is what the terminal's own resume
320    /// wizard defaults to.
321    #[serde(default)]
322    pub queue: Option<mj_core::state::ResumeQueueDisposition>,
323}
324
325/// What a resume was accepted as: the settings it will actually use, resolved
326/// from the request and the session's record.
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
328pub struct ResumeSessionResponse {
329    pub session_id: String,
330    pub workspace_id: String,
331    pub profile_id: String,
332    pub target_id: String,
333}
334
335/// What a suspend was accepted as. A suspend stops the session's active
336/// Mjolnir sub-agents without a checkpoint and suspends the session alone.
337#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
338pub struct SuspendSessionResponse {
339    #[serde(default)]
340    pub session_id: String,
341    /// Active sub-agents the suspend stops.
342    #[serde(default)]
343    pub stopped_subagents: usize,
344    /// How many of those have not handed back their report.
345    #[serde(default)]
346    pub subagents_not_handed_back: usize,
347    /// "N sub-agents have not handed back; suspending stops them", when any
348    /// have not.
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub warning: Option<String>,
351}
352
353#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
354pub struct StartSessionResponse {
355    pub session_id: String,
356    /// The turn the follow-up prompt was accepted as, once it has been
357    /// submitted. Creation answers before that, so it is usually absent.
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub turn_id: Option<u64>,
360}
361
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(deny_unknown_fields)]
364pub struct SpawnSubagentRequest {
365    pub task_name: String,
366    pub instructions: String,
367    #[serde(default)]
368    pub task_type: Option<mj_core::subagent::SubagentTaskType>,
369    #[serde(default)]
370    pub working_directory: Option<PathBuf>,
371}
372
373/// A candidate profile with its discovered choices and remaining quota.
374#[derive(Debug, Clone, PartialEq, Eq)]
375pub struct SubagentCandidate {
376    pub profile_id: String,
377    pub harness: mj_core::config::HarnessKind,
378    pub choices: mj_core::worker_launch::ProfileConfig,
379    /// The lower of the profile's quota windows (the 5-hour and weekly ones
380    /// for Codex and Claude), 100 for a pay-per-use profile, and `None` when
381    /// no usable report exists.
382    pub remaining_percent: Option<u8>,
383}
384
385/// Candidate profiles split into those whose choices are known and those
386/// whose discovery failed, with the reason.
387#[derive(Debug, Clone, Default, PartialEq, Eq)]
388pub struct SubagentCandidates {
389    pub offered: Vec<SubagentCandidate>,
390    pub unavailable: Vec<(String, String)>,
391}
392
393#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
394pub struct SubagentView {
395    pub parent_session_id: String,
396    pub task_name: String,
397    pub session: ApiSession,
398}
399
400#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
401pub struct SubagentListResponse {
402    pub subagents: Vec<SubagentView>,
403}
404
405#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
406#[serde(deny_unknown_fields)]
407pub struct PromptRequest {
408    pub text: String,
409    /// Caller-chosen relay command identity for recovery after a lost reply.
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub command_id: Option<String>,
412}
413
414#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
415#[serde(deny_unknown_fields)]
416pub struct ClearQueueRequest {
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub command_id: Option<String>,
419}
420
421#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
422pub struct PromptResponse {
423    /// The relay acceptance ordinal for this prompt, which is what `wait`
424    /// takes as `turn_id`.
425    pub turn_id: u64,
426}
427
428#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
429#[serde(deny_unknown_fields)]
430pub struct WaitRequest {
431    /// Return when the harness presents a structured input request.
432    #[serde(default)]
433    pub return_on_input: bool,
434    /// Wait for this specific prompt. Absent means "wait until the session is
435    /// idle with nothing queued", which is what a caller that lost its turn id
436    /// wants.
437    #[serde(default)]
438    pub turn_id: Option<u64>,
439    #[serde(default)]
440    pub timeout_secs: Option<u64>,
441}
442
443/// How a wait ended.
444#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
445#[serde(rename_all = "snake_case")]
446pub enum WaitOutcome {
447    /// A structured elicitation needs an answer; only returned by opt-in waits.
448    InputRequired,
449    /// The turn completed normally.
450    Finished,
451    /// The turn failed, was rejected, or the session reported an error.
452    Error,
453    /// The turn was cancelled or interrupted.
454    Cancelled,
455    /// The model was at capacity and no retry is armed.
456    QuotaLimit,
457    /// The wait's deadline passed with the turn still running.
458    Timeout,
459    /// The session stopped or is stopping, so no turn can finish on it.
460    Stopped,
461}
462
463#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
464pub struct WaitCapacityRetry {
465    pub attempt: u32,
466    pub retry_at_ms: i64,
467}
468
469impl From<&CapacityRetry> for WaitCapacityRetry {
470    fn from(retry: &CapacityRetry) -> Self {
471        Self {
472            attempt: retry.attempt,
473            retry_at_ms: retry.retry_at_ms,
474        }
475    }
476}
477
478/// How the daemon's live view of a session's relay is doing.
479///
480/// This reports; it never decides an outcome. A caller that gets `timeout`
481/// needs to tell "the turn is still working" from "the daemon cannot see the
482/// worker at all", and those look identical without it.
483#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
484#[serde(rename_all = "snake_case")]
485pub enum RelayState {
486    /// The daemon is attached to the worker and following its events.
487    Connected,
488    /// Not attached, with no error recorded yet: attaching, or between tries.
489    Disconnected,
490    /// The worker could not be reached.
491    Unreachable,
492    /// The session's target is gone.
493    TargetMissing,
494    /// The event stream did not line up with what the daemon had projected.
495    ProjectionIntegrity,
496}
497
498#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
499pub struct RelayHealth {
500    pub state: RelayState,
501    /// The view's own description of the problem, when it recorded one.
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    pub detail: Option<String>,
504}
505
506impl From<&mj_client::session::ManagedSessionView> for RelayHealth {
507    fn from(view: &mj_client::session::ManagedSessionView) -> Self {
508        use mj_client::session::ViewError;
509        // A recorded error outranks `connected`: it is the specific thing
510        // standing between the caller and a finished turn.
511        match &view.error {
512            Some(error) => Self {
513                state: match error {
514                    ViewError::Unreachable(_) => RelayState::Unreachable,
515                    ViewError::TargetMissing(_) => RelayState::TargetMissing,
516                    ViewError::ProjectionIntegrity(_) => RelayState::ProjectionIntegrity,
517                },
518                detail: Some(error.detail().to_owned()),
519            },
520            None if view.connected => Self {
521                state: RelayState::Connected,
522                detail: None,
523            },
524            None => Self {
525                state: RelayState::Disconnected,
526                detail: None,
527            },
528        }
529    }
530}
531
532#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
533pub struct WaitResponse {
534    #[serde(default, skip_serializing_if = "Option::is_none")]
535    pub diagnostic: Option<mj_core::diagnostic::TurnDiagnostic>,
536
537    #[serde(default, skip_serializing_if = "Vec::is_empty")]
538    pub pending_elicitations: Vec<mj_core::elicitation::ElicitationRequest>,
539    #[serde(default, skip_serializing_if = "Option::is_none")]
540    pub usage: Option<mj_core::usage::TokenUsage>,
541    pub outcome: WaitOutcome,
542    /// The harness's own stop reason, when the turn reached one.
543    #[serde(default, skip_serializing_if = "Option::is_none")]
544    pub stop_reason: Option<String>,
545    /// Why the wait ended this way, when there is something to say.
546    #[serde(default, skip_serializing_if = "Option::is_none")]
547    pub message: Option<String>,
548    /// The agent's last message of the turn, flattened to text. For a
549    /// sub-agent child it is the report the child handed back, when it did.
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    pub final_message: Option<String>,
552    /// For a sub-agent child: `handback` when `final_message` is the report
553    /// the child handed back, `last_message` when it is the turn's last
554    /// message. Absent for every other session.
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    pub report_source: Option<String>,
557    /// The turn this answer is about: the latest turn that ended at or after
558    /// the requested one, since a session keeps only its latest turn's outcome.
559    #[serde(default, skip_serializing_if = "Option::is_none")]
560    pub turn_id: Option<u64>,
561    /// The turn the request named, so a caller can see when `turn_id` is a
562    /// later one.
563    #[serde(default, skip_serializing_if = "Option::is_none")]
564    pub requested_turn_id: Option<u64>,
565    /// One-based position of this turn in the conversation.
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub turn_number: Option<u64>,
568    #[serde(default, skip_serializing_if = "Option::is_none")]
569    pub elapsed_ms: Option<i64>,
570    /// How many tool calls the turn made. Absent when the wait ended without a
571    /// finished turn.
572    #[serde(default, skip_serializing_if = "Option::is_none")]
573    pub tool_calls: Option<u64>,
574    /// Legacy alias for a worker-owned server retry, retained for older clients.
575    #[serde(default, skip_serializing_if = "Option::is_none")]
576    pub capacity_retry: Option<WaitCapacityRetry>,
577    #[serde(default, skip_serializing_if = "Option::is_none")]
578    pub server_retry: Option<WaitCapacityRetry>,
579    #[serde(default)]
580    pub retry_assessment_pending: bool,
581    #[serde(default, skip_serializing_if = "Option::is_none")]
582    pub quota_recovery: Option<mj_core::continuation::QuotaRecovery>,
583    /// The health of the daemon's live view of this session. Absent when no
584    /// live actor holds the session, because there is then no view to report
585    /// on and inventing one would be worse than saying nothing.
586    #[serde(default, skip_serializing_if = "Option::is_none")]
587    pub relay: Option<RelayHealth>,
588    pub session: ApiSession,
589}
590
591/// How many transcript items a page carries when the caller names no limit,
592/// and the most it may ask for. A caller that asks for more gets the ceiling
593/// rather than an error: paging is the point, and refusing a large limit would
594/// only make the caller retry with a smaller one.
595pub const DEFAULT_TRANSCRIPT_LIMIT: usize = 200;
596pub const MAX_TRANSCRIPT_LIMIT: usize = 1_000;
597
598#[derive(Debug, Clone, Default, PartialEq, Serialize)]
599pub struct TranscriptQuery {
600    pub role: Vec<mj_core::transcript::TranscriptRole>,
601    /// Return only closed agent messages and keep the cursor before any open one.
602    pub finished_only: bool,
603    /// Resume from the highest sequence the caller has already seen.
604    pub after_seq: Option<u64>,
605    pub limit: Option<usize>,
606}
607
608impl<'de> serde::Deserialize<'de> for TranscriptQuery {
609    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
610    where
611        D: serde::Deserializer<'de>,
612    {
613        struct TranscriptQueryVisitor;
614
615        impl<'de> serde::de::Visitor<'de> for TranscriptQueryVisitor {
616            type Value = TranscriptQuery;
617
618            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
619                formatter.write_str("transcript query parameters")
620            }
621
622            fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
623            where
624                M: serde::de::MapAccess<'de>,
625            {
626                let mut role = Vec::new();
627                let mut finished_only = None;
628                let mut after_seq = None;
629                let mut limit = None;
630
631                while let Some(key) = map.next_key::<String>()? {
632                    match key.as_str() {
633                        "role" => role.push(map.next_value()?),
634                        "finished_only" => {
635                            if finished_only.replace(map.next_value()?).is_some() {
636                                return Err(serde::de::Error::duplicate_field("finished_only"));
637                            }
638                        }
639                        "after_seq" => {
640                            if after_seq.replace(map.next_value()?).is_some() {
641                                return Err(serde::de::Error::duplicate_field("after_seq"));
642                            }
643                        }
644                        "limit" => {
645                            if limit.replace(map.next_value()?).is_some() {
646                                return Err(serde::de::Error::duplicate_field("limit"));
647                            }
648                        }
649                        _ => {
650                            let _: serde::de::IgnoredAny = map.next_value()?;
651                        }
652                    }
653                }
654
655                Ok(TranscriptQuery {
656                    role,
657                    finished_only: finished_only.unwrap_or_default(),
658                    after_seq,
659                    limit,
660                })
661            }
662        }
663
664        deserializer.deserialize_map(TranscriptQueryVisitor)
665    }
666}
667
668#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
669pub struct TranscriptItemView {
670    pub stable_id: String,
671    pub position: u64,
672    /// What to pass as the next `after_seq`. It is the position for everything
673    /// but an agent message, which carries the ordinal of its latest content.
674    pub seq: u64,
675    pub role: String,
676    /// The item flattened to text, which is what a reading caller wants.
677    pub text: String,
678    pub created_at_ms: i64,
679    pub last_changed_at_ms: i64,
680    /// The stored body, for a caller that needs the structure behind the text.
681    pub body: mj_core::transcript::TranscriptBody,
682}
683
684#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
685pub struct TranscriptResponse {
686    #[serde(default)]
687    pub next_after_seq: u64,
688    pub session_id: String,
689    /// The newest sequence in the whole transcript. A page whose last item
690    /// reaches this is up to date.
691    pub latest_seq: u64,
692    pub execution: MaterializedExecutionState,
693    pub items: Vec<TranscriptItemView>,
694}
695
696// ---------------------------------------------------------------------------
697// Backend
698// ---------------------------------------------------------------------------
699
700/// Where a session stands turn by turn, read from the durable projection when
701/// no live actor holds the session.
702#[derive(Debug, Clone, PartialEq, Eq)]
703pub struct TurnState {
704    pub execution: MaterializedExecutionState,
705    pub active_turn: Option<MaterializedTurn>,
706    pub last_turn_outcome: Option<MaterializedTurnOutcome>,
707}
708
709pub use crate::database::TurnSummary;
710
711/// Configuration and a first prompt to apply once a newly created session's
712/// harness is ready. Served in M2.
713#[derive(Debug, Clone, Default, PartialEq, Eq)]
714pub struct StartFollowup {
715    pub model: Option<String>,
716    pub effort: Option<String>,
717    pub prompt: Option<String>,
718    pub fast_mode: bool,
719}
720
721/// How far a created session's follow-up has got. Served in M2.
722#[derive(Debug, Clone, PartialEq, Eq)]
723pub enum StartStatus {
724    /// The session is still provisioning, or its harness is not ready.
725    Pending,
726    /// The follow-up prompt was submitted and accepted as this turn.
727    Submitted { turn_id: u64 },
728    /// The session could not be started, or the follow-up could not be applied.
729    Failed { message: String },
730}
731
732/// A page of transcript items, read from the durable projection.
733pub use crate::database::TranscriptPage;
734
735/// A branch the daemon pushed on the caller's behalf.
736#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
737pub struct PushedBranch {
738    pub branch: String,
739    pub remote: String,
740}
741
742/// Which file of the session's workspace to read.
743#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
744pub struct FileQuery {
745    /// Path relative to the session's workspace root.
746    pub path: String,
747}
748
749/// What form the caller wants the session's work in.
750#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
751#[serde(rename_all = "snake_case")]
752pub enum ExportKind {
753    /// A unified diff, as `GET /diff` returns.
754    Patch,
755    /// A branch pushed to the repository's push remote.
756    Branch,
757    /// The git bundle of the session's committed work.
758    Bundle,
759}
760
761#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
762pub struct ExportRequest {
763    pub kind: ExportKind,
764    /// The branch to push. Required when `kind` is `branch`.
765    #[serde(default, skip_serializing_if = "Option::is_none")]
766    pub branch: Option<String>,
767}
768
769/// A git bundle of the session's work.
770#[derive(Debug, Clone)]
771pub struct BundleExport {
772    pub repository: String,
773    pub bytes: Vec<u8>,
774}
775
776/// Why an export could not be produced. Served in M4.
777#[derive(Debug)]
778pub enum ExportError {
779    /// The session is in a state where this export is not possible. The caller
780    /// can act on it, so it answers 409.
781    Refused(String),
782    /// The export was attempted and failed.
783    Failed(anyhow::Error),
784}
785
786impl From<ExportError> for ApiFailure {
787    fn from(error: ExportError) -> Self {
788        match error {
789            ExportError::Refused(message) => Self::conflict(message),
790            ExportError::Failed(error) => Self::from(error),
791        }
792    }
793}
794
795/// What `POST /sessions/{id}/review` was accepted as: the review of the turn
796/// the session just finished has started.
797#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
798pub struct StartReviewResponse {
799    pub session_id: String,
800    pub started: bool,
801}
802
803/// What `POST /sessions/{id}/review/{resolution}` was accepted as.
804#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
805pub struct ResolveReviewResponse {
806    pub session_id: String,
807    /// `forward`, `dismiss`, or `cancel`.
808    pub resolution: String,
809}
810
811/// The turn review a session has open, as `GET /sessions/{id}/review`
812/// reports it: the same view a phone renders, or `None` when no review is
813/// open (none was asked for, or the last one has closed).
814#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
815pub struct ReviewStatusResponse {
816    pub session_id: String,
817    #[serde(default)]
818    pub review: Option<crate::server::ViewerTurnReview>,
819}
820
821// ---------------------------------------------------------------------------
822// SessionWiki
823// ---------------------------------------------------------------------------
824
825#[derive(Debug, Default, Deserialize)]
826#[serde(deny_unknown_fields)]
827pub struct WikiSearchQuery {
828    #[serde(default)]
829    pub q: Option<String>,
830    #[serde(default)]
831    pub limit: Option<usize>,
832}
833
834#[derive(Debug, Default, Deserialize)]
835#[serde(deny_unknown_fields)]
836pub struct WikiBriefQuery {
837    #[serde(default)]
838    pub max_chars: Option<usize>,
839}
840
841#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
842pub struct WikiBriefResponse {
843    pub markdown: String,
844}
845
846/// The query for the matching passages of one indexed session.
847#[derive(Debug, Default, Deserialize)]
848#[serde(deny_unknown_fields)]
849pub struct WikiHitsQuery {
850    pub q: String,
851    #[serde(default)]
852    pub context_messages: Option<usize>,
853    #[serde(default)]
854    pub per_message_chars: Option<usize>,
855}
856
857/// The fields of a start request a restore needs. The archived session decides
858/// the rest: its title, and the project it ran in when the caller names none.
859#[derive(Debug, Clone, Default, Serialize, Deserialize)]
860#[serde(deny_unknown_fields)]
861pub struct WikiRestoreBody {
862    #[serde(default)]
863    pub workspace_id: Option<String>,
864    pub profile_id: String,
865    pub target_id: String,
866    #[serde(default)]
867    pub project_directory: Option<PathBuf>,
868    #[serde(default)]
869    pub model: Option<String>,
870    #[serde(default)]
871    pub effort: Option<String>,
872}
873
874// ---------------------------------------------------------------------------
875// Launch options
876// ---------------------------------------------------------------------------
877
878/// What a caller may choose when it starts a session, and which pair to use
879/// when it chooses nothing.
880///
881/// Served by `GET /api/v1/options` so a caller that has never read
882/// `config.toml` can enumerate the profiles and targets this daemon knows,
883/// learn whether each host answered its last check, and read the remembered
884/// default. It is the public projection in `server/viewer_types.rs`, narrowed
885/// to what a launch decision needs: no harness home, no SSH host or key, no
886/// container environment, no AWS detail, and no controller-side path.
887#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
888#[serde(deny_unknown_fields)]
889pub struct LaunchOptions {
890    /// The projection revision these lists came from. Two reads carrying the
891    /// same revision describe the same configuration, so a caller can build a
892    /// form without mixing halves of two different worlds.
893    pub revision: u64,
894    pub profiles: Vec<LaunchProfile>,
895    pub targets: Vec<LaunchTarget>,
896    pub bundles: Vec<LaunchBundle>,
897    /// One entry per host that has published a capacity reading. The label is
898    /// how a person names the host; it is never a locator or an address.
899    #[serde(default, skip_serializing_if = "Vec::is_empty")]
900    pub hosts: Vec<LaunchHost>,
901    /// The pair the user last saved as their default, which the first setup
902    /// also becomes. Absent when nothing has ever been saved, and never an
903    /// error: a caller that cannot read a preference still has the lists.
904    #[serde(default, skip_serializing_if = "Option::is_none")]
905    pub default: Option<LaunchDefault>,
906}
907
908/// One account this daemon can run work under.
909#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
910#[serde(deny_unknown_fields)]
911pub struct LaunchProfile {
912    pub id: String,
913    /// The harness kind, such as `codex` or `claude`. Which account it is, and
914    /// where its credentials live, stay on the controller.
915    pub harness: String,
916}
917
918/// One runtime template a session can run on.
919#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
920#[serde(deny_unknown_fields)]
921pub struct LaunchTarget {
922    pub id: String,
923    /// The runtime kind, such as `local-bare` or `ssh-podman`.
924    pub kind: String,
925    /// Whether this target needs an existing Git directory on its machine
926    /// instead of provisioning repositories into a workspace.
927    pub requires_project_directory: bool,
928    pub availability: LaunchAvailability,
929    /// What to tell a person when the host did not answer. This repository
930    /// composes the sentence: a probe's own message names hosts and commands
931    /// and stays on the controller.
932    #[serde(default, skip_serializing_if = "Option::is_none")]
933    pub unavailable_reason: Option<String>,
934    /// Whether the target's runtime (Docker, Podman) is not installed on the
935    /// daemon's host. That is permanent for the host, unlike a host that did
936    /// not answer its last check: a picker leaves such a target out, and a
937    /// request that names it is refused with `unavailable_reason`.
938    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
939    pub runtime_missing: bool,
940    /// Whether the target is a default candidate Mjolnir supplies, not one
941    /// the user wrote in `config.toml`. With `runtime_missing` it means the
942    /// user has no such runtime and never asked for the target, so a client
943    /// should not list it.
944    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
945    pub default_candidate: bool,
946    /// How a person names the host, when a reading covers this target.
947    #[serde(default, skip_serializing_if = "Option::is_none")]
948    pub host: Option<String>,
949}
950
951/// How much this daemon knows about a target's host.
952///
953/// A reading arrives from a background poll, so it can be absent, old, or
954/// failed. Only `Unavailable` is a statement that the target cannot be used;
955/// `Unknown` means nobody has checked yet, which is the ordinary state
956/// immediately after startup.
957#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
958#[serde(rename_all = "lowercase")]
959pub enum LaunchAvailability {
960    /// A reading covers this target and its last check succeeded.
961    Ready,
962    /// A reading covers this target but is marked stale.
963    Stale,
964    /// A reading covers this target and its last check failed.
965    Unavailable,
966    /// No reading covers this target yet.
967    Unknown,
968}
969
970/// One repository set a managed target can provision.
971#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
972#[serde(deny_unknown_fields)]
973pub struct LaunchBundle {
974    pub id: String,
975    pub primary_repository: String,
976    pub repositories: Vec<LaunchRepository>,
977}
978
979#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
980#[serde(deny_unknown_fields)]
981pub struct LaunchRepository {
982    pub id: String,
983    /// The GitHub source, when this repository has one. A repository sourced
984    /// from a local directory publishes nothing here, because that source is a
985    /// path on the controller.
986    #[serde(default, skip_serializing_if = "Option::is_none")]
987    pub github: Option<String>,
988    pub destination: String,
989}
990
991/// One host or fleet, as much as a caller needs to explain an unavailable
992/// target. The probe's own error text is deliberately absent.
993#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
994#[serde(deny_unknown_fields)]
995pub struct LaunchHost {
996    pub id: String,
997    pub label: String,
998    /// The targets this reading covers.
999    pub targets: Vec<String>,
1000    pub stale: bool,
1001    pub refreshing: bool,
1002    pub has_error: bool,
1003}
1004
1005/// The pair a caller may leave unnamed when it starts a session.
1006#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1007#[serde(deny_unknown_fields)]
1008pub struct LaunchDefault {
1009    pub profile_id: String,
1010    pub target_id: String,
1011}