Skip to main content

mj_controller/server/api/
types.rs

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