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///
130/// `profile_id` and `target_id` may be omitted, and each resolves
131/// independently: a caller may name a profile and take the saved default
132/// target. An omitted identifier comes from the pair the user last saved with
133/// the `mj go` workflow, which the first setup also becomes, so a caller that
134/// has never read `config.toml` can create a session by naming neither. The
135/// controller still receives two explicit identifiers, because a session whose
136/// profile was implicit would be a session nobody can explain later.
137#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
138#[serde(deny_unknown_fields)]
139pub struct StartSessionRequest {
140    #[serde(default)]
141    pub create_managed_worktree: Option<bool>,
142    /// Git revision the session starts at, as the caller typed it.
143    #[serde(default)]
144    pub launch_base: Option<String>,
145    /// None follows the global `[subagents] enabled` setting.
146    #[serde(default)]
147    pub mjolnir_subagents: Option<bool>,
148    #[serde(default)]
149    pub workspace_id: Option<String>,
150    /// Omitted follows the saved default. See the type's own documentation.
151    #[serde(default)]
152    pub profile_id: Option<String>,
153    /// Omitted follows the saved default. See the type's own documentation.
154    #[serde(default)]
155    pub target_id: Option<String>,
156    #[serde(default)]
157    pub bundle_id: Option<String>,
158    #[serde(default)]
159    pub project_directory: Option<PathBuf>,
160    #[serde(default)]
161    pub title: Option<String>,
162    #[serde(default)]
163    pub model: Option<String>,
164    #[serde(default)]
165    pub effort: Option<String>,
166    #[serde(default)]
167    pub prompt: Option<String>,
168}
169
170/// Resume a stopped, lost, or failed session. Every field is optional: the
171/// session's own record supplies what the caller does not name, which is what
172/// makes `POST .../resume` with no body the scriptable "continue this session"
173/// call.
174#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(deny_unknown_fields)]
176pub struct ResumeSessionRequest {
177    /// Profile to resume on. Defaults to the one the session last ran.
178    #[serde(default)]
179    pub profile_id: Option<String>,
180    /// Target template to provision. Defaults to the session's own.
181    #[serde(default)]
182    pub target_id: Option<String>,
183    /// Workspace the resumed session belongs to. Defaults to its own.
184    #[serde(default)]
185    pub workspace_id: Option<String>,
186    /// Whether prompts queued when the session stopped are started or
187    /// discarded. Defaults to `start`, which is what the terminal's own resume
188    /// wizard defaults to.
189    #[serde(default)]
190    pub queue: Option<mj_core::state::ResumeQueueDisposition>,
191}
192
193/// What a resume was accepted as: the settings it will actually use, resolved
194/// from the request and the session's record.
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196pub struct ResumeSessionResponse {
197    pub session_id: String,
198    pub workspace_id: String,
199    pub profile_id: String,
200    pub target_id: String,
201}
202
203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
204pub struct StartSessionResponse {
205    pub session_id: String,
206    /// The turn the follow-up prompt was accepted as, once it has been
207    /// submitted. Creation answers before that, so it is usually absent.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub turn_id: Option<u64>,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(deny_unknown_fields)]
214pub struct SubagentSourceRange {
215    pub file: PathBuf,
216    pub start: u64,
217    pub end: u64,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(deny_unknown_fields)]
222pub struct SpawnSubagentRequest {
223    pub task_name: String,
224    pub instructions: String,
225    #[serde(default)]
226    pub profile_id: Option<String>,
227    #[serde(default)]
228    pub model: Option<String>,
229    #[serde(default)]
230    pub effort: Option<String>,
231    #[serde(default)]
232    pub working_directory: Option<PathBuf>,
233    #[serde(default)]
234    pub context: Option<String>,
235    #[serde(default)]
236    pub files: Vec<SubagentSourceRange>,
237    pub request_key: String,
238}
239
240#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
241pub struct SubagentView {
242    pub parent_session_id: String,
243    pub task_name: String,
244    pub request_key: String,
245    pub session: ApiSession,
246}
247
248#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
249pub struct SubagentListResponse {
250    pub subagents: Vec<SubagentView>,
251}
252
253#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
254#[serde(deny_unknown_fields)]
255pub struct PromptRequest {
256    pub text: String,
257}
258
259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
260pub struct PromptResponse {
261    /// The relay acceptance ordinal for this prompt, which is what `wait`
262    /// takes as `turn_id`.
263    pub turn_id: u64,
264}
265
266#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
267#[serde(deny_unknown_fields)]
268pub struct WaitRequest {
269    /// Return when the harness presents a structured input request.
270    #[serde(default)]
271    pub return_on_input: bool,
272    /// Wait for this specific prompt. Absent means "wait until the session is
273    /// idle with nothing queued", which is what a caller that lost its turn id
274    /// wants.
275    #[serde(default)]
276    pub turn_id: Option<u64>,
277    #[serde(default)]
278    pub timeout_secs: Option<u64>,
279}
280
281/// How a wait ended.
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
283#[serde(rename_all = "snake_case")]
284pub enum WaitOutcome {
285    /// A structured elicitation needs an answer; only returned by opt-in waits.
286    InputRequired,
287    /// The turn completed normally.
288    Finished,
289    /// The turn failed, was rejected, or the session reported an error.
290    Error,
291    /// The turn was cancelled or interrupted.
292    Cancelled,
293    /// The model was at capacity and no retry is armed.
294    QuotaLimit,
295    /// The wait's deadline passed with the turn still running.
296    Timeout,
297    /// The session stopped or is stopping, so no turn can finish on it.
298    Stopped,
299}
300
301#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
302pub struct WaitCapacityRetry {
303    pub attempt: u32,
304    pub retry_at_ms: i64,
305}
306
307impl From<&CapacityRetry> for WaitCapacityRetry {
308    fn from(retry: &CapacityRetry) -> Self {
309        Self {
310            attempt: retry.attempt,
311            retry_at_ms: retry.retry_at_ms,
312        }
313    }
314}
315
316/// How the daemon's live view of a session's relay is doing.
317///
318/// This reports; it never decides an outcome. A caller that gets `timeout`
319/// needs to tell "the turn is still working" from "the daemon cannot see the
320/// worker at all", and those look identical without it.
321#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
322#[serde(rename_all = "snake_case")]
323pub enum RelayState {
324    /// The daemon is attached to the worker and following its events.
325    Connected,
326    /// Not attached, with no error recorded yet: attaching, or between tries.
327    Disconnected,
328    /// The worker could not be reached.
329    Unreachable,
330    /// The session's target is gone.
331    TargetMissing,
332    /// The event stream did not line up with what the daemon had projected.
333    ProjectionIntegrity,
334}
335
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
337pub struct RelayHealth {
338    pub state: RelayState,
339    /// The view's own description of the problem, when it recorded one.
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub detail: Option<String>,
342}
343
344impl From<&mj_client::session::ManagedSessionView> for RelayHealth {
345    fn from(view: &mj_client::session::ManagedSessionView) -> Self {
346        use mj_client::session::ViewError;
347        // A recorded error outranks `connected`: it is the specific thing
348        // standing between the caller and a finished turn.
349        match &view.error {
350            Some(error) => Self {
351                state: match error {
352                    ViewError::Unreachable(_) => RelayState::Unreachable,
353                    ViewError::TargetMissing(_) => RelayState::TargetMissing,
354                    ViewError::ProjectionIntegrity(_) => RelayState::ProjectionIntegrity,
355                },
356                detail: Some(error.detail().to_owned()),
357            },
358            None if view.connected => Self {
359                state: RelayState::Connected,
360                detail: None,
361            },
362            None => Self {
363                state: RelayState::Disconnected,
364                detail: None,
365            },
366        }
367    }
368}
369
370#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
371pub struct WaitResponse {
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub diagnostic: Option<mj_core::diagnostic::TurnDiagnostic>,
374
375    #[serde(default, skip_serializing_if = "Vec::is_empty")]
376    pub pending_elicitations: Vec<mj_core::elicitation::ElicitationRequest>,
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub usage: Option<mj_core::usage::TokenUsage>,
379    pub outcome: WaitOutcome,
380    /// The harness's own stop reason, when the turn reached one.
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub stop_reason: Option<String>,
383    /// Why the wait ended this way, when there is something to say.
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    pub message: Option<String>,
386    /// The agent's last message of the turn, flattened to text.
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub final_message: Option<String>,
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub turn_id: Option<u64>,
391    /// One-based position of this turn in the conversation.
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub turn_number: Option<u64>,
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub elapsed_ms: Option<i64>,
396    /// A capacity retry the worker has armed. While one is pending the caller
397    /// must not submit its own prompt: it would collide with the retry.
398    #[serde(default, skip_serializing_if = "Option::is_none")]
399    pub capacity_retry: Option<WaitCapacityRetry>,
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub quota_recovery: Option<mj_core::continuation::QuotaRecovery>,
402    /// The health of the daemon's live view of this session. Absent when no
403    /// live actor holds the session, because there is then no view to report
404    /// on and inventing one would be worse than saying nothing.
405    #[serde(default, skip_serializing_if = "Option::is_none")]
406    pub relay: Option<RelayHealth>,
407    pub session: ApiSession,
408}
409
410/// How many transcript items a page carries when the caller names no limit,
411/// and the most it may ask for. A caller that asks for more gets the ceiling
412/// rather than an error: paging is the point, and refusing a large limit would
413/// only make the caller retry with a smaller one.
414pub const DEFAULT_TRANSCRIPT_LIMIT: usize = 200;
415pub const MAX_TRANSCRIPT_LIMIT: usize = 1_000;
416
417#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
418pub struct TranscriptQuery {
419    #[serde(default)]
420    pub role: Option<mj_core::transcript::TranscriptRole>,
421    /// Resume from the highest sequence the caller has already seen.
422    #[serde(default)]
423    pub after_seq: Option<u64>,
424    #[serde(default)]
425    pub limit: Option<usize>,
426}
427
428#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
429pub struct TranscriptItemView {
430    pub stable_id: String,
431    pub position: u64,
432    /// What to pass as the next `after_seq`. It is the position for everything
433    /// but an agent message, which carries the ordinal of its latest content.
434    pub seq: u64,
435    pub role: String,
436    /// The item flattened to text, which is what a reading caller wants.
437    pub text: String,
438    pub created_at_ms: i64,
439    pub last_changed_at_ms: i64,
440    /// The stored body, for a caller that needs the structure behind the text.
441    pub body: mj_core::transcript::TranscriptBody,
442}
443
444#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
445pub struct TranscriptResponse {
446    #[serde(default)]
447    pub next_after_seq: u64,
448    pub session_id: String,
449    /// The newest sequence in the whole transcript. A page whose last item
450    /// reaches this is up to date.
451    pub latest_seq: u64,
452    pub execution: MaterializedExecutionState,
453    pub items: Vec<TranscriptItemView>,
454}
455
456// ---------------------------------------------------------------------------
457// Backend
458// ---------------------------------------------------------------------------
459
460/// Where a session stands turn by turn, read from the durable projection when
461/// no live actor holds the session.
462#[derive(Debug, Clone, PartialEq, Eq)]
463pub struct TurnState {
464    pub execution: MaterializedExecutionState,
465    pub active_turn: Option<MaterializedTurn>,
466    pub last_turn_outcome: Option<MaterializedTurnOutcome>,
467}
468
469pub use crate::database::TurnSummary;
470
471/// Configuration and a first prompt to apply once a newly created session's
472/// harness is ready. Served in M2.
473#[derive(Debug, Clone, Default, PartialEq, Eq)]
474pub struct StartFollowup {
475    pub model: Option<String>,
476    pub effort: Option<String>,
477    pub prompt: Option<String>,
478}
479
480/// How far a created session's follow-up has got. Served in M2.
481#[derive(Debug, Clone, PartialEq, Eq)]
482pub enum StartStatus {
483    /// The session is still provisioning, or its harness is not ready.
484    Pending,
485    /// The follow-up prompt was submitted and accepted as this turn.
486    Submitted { turn_id: u64 },
487    /// The session could not be started, or the follow-up could not be applied.
488    Failed { message: String },
489}
490
491/// A page of transcript items, read from the durable projection.
492pub use crate::database::TranscriptPage;
493
494/// A branch the daemon pushed on the caller's behalf.
495#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
496pub struct PushedBranch {
497    pub branch: String,
498    pub remote: String,
499}
500
501/// Which file of the session's workspace to read.
502#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
503pub struct FileQuery {
504    /// Path relative to the session's workspace root.
505    pub path: String,
506}
507
508/// What form the caller wants the session's work in.
509#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
510#[serde(rename_all = "snake_case")]
511pub enum ExportKind {
512    /// A unified diff, as `GET /diff` returns.
513    Patch,
514    /// A branch pushed to the repository's push remote.
515    Branch,
516    /// The git bundle of the session's committed work.
517    Bundle,
518}
519
520#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
521pub struct ExportRequest {
522    pub kind: ExportKind,
523    /// The branch to push. Required when `kind` is `branch`.
524    #[serde(default, skip_serializing_if = "Option::is_none")]
525    pub branch: Option<String>,
526}
527
528/// A git bundle of the session's work.
529#[derive(Debug, Clone)]
530pub struct BundleExport {
531    pub repository: String,
532    pub bytes: Vec<u8>,
533}
534
535/// Why an export could not be produced. Served in M4.
536#[derive(Debug)]
537pub enum ExportError {
538    /// The session is in a state where this export is not possible. The caller
539    /// can act on it, so it answers 409.
540    Refused(String),
541    /// The export was attempted and failed.
542    Failed(anyhow::Error),
543}
544
545impl From<ExportError> for ApiFailure {
546    fn from(error: ExportError) -> Self {
547        match error {
548            ExportError::Refused(message) => Self::conflict(message),
549            ExportError::Failed(error) => Self::from(error),
550        }
551    }
552}
553
554// ---------------------------------------------------------------------------
555// SessionWiki
556// ---------------------------------------------------------------------------
557
558#[derive(Debug, Default, Deserialize)]
559#[serde(deny_unknown_fields)]
560pub struct WikiSearchQuery {
561    #[serde(default)]
562    pub q: Option<String>,
563    #[serde(default)]
564    pub limit: Option<usize>,
565}
566
567#[derive(Debug, Default, Deserialize)]
568#[serde(deny_unknown_fields)]
569pub struct WikiBriefQuery {
570    #[serde(default)]
571    pub max_chars: Option<usize>,
572}
573
574#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
575pub struct WikiBriefResponse {
576    pub markdown: String,
577}
578
579/// The query for the matching passages of one indexed session.
580#[derive(Debug, Default, Deserialize)]
581#[serde(deny_unknown_fields)]
582pub struct WikiHitsQuery {
583    pub q: String,
584    #[serde(default)]
585    pub context_messages: Option<usize>,
586    #[serde(default)]
587    pub per_message_chars: Option<usize>,
588}
589
590/// The fields of a start request a restore needs. The archived session decides
591/// the rest: its title, and the project it ran in when the caller names none.
592#[derive(Debug, Clone, Default, Serialize, Deserialize)]
593#[serde(deny_unknown_fields)]
594pub struct WikiRestoreBody {
595    #[serde(default)]
596    pub workspace_id: Option<String>,
597    pub profile_id: String,
598    pub target_id: String,
599    #[serde(default)]
600    pub project_directory: Option<PathBuf>,
601    #[serde(default)]
602    pub model: Option<String>,
603    #[serde(default)]
604    pub effort: Option<String>,
605}
606
607// ---------------------------------------------------------------------------
608// Launch options
609// ---------------------------------------------------------------------------
610
611/// What a caller may choose when it starts a session, and which pair to use
612/// when it chooses nothing.
613///
614/// Served by `GET /api/v1/options` so a caller that has never read
615/// `config.toml` can enumerate the profiles and targets this daemon knows,
616/// learn whether each host answered its last check, and read the remembered
617/// default. It is the public projection in `server/viewer_types.rs`, narrowed
618/// to what a launch decision needs: no harness home, no SSH host or key, no
619/// container environment, no AWS detail, and no controller-side path.
620#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
621#[serde(deny_unknown_fields)]
622pub struct LaunchOptions {
623    /// The projection revision these lists came from. Two reads carrying the
624    /// same revision describe the same configuration, so a caller can build a
625    /// form without mixing halves of two different worlds.
626    pub revision: u64,
627    pub profiles: Vec<LaunchProfile>,
628    pub targets: Vec<LaunchTarget>,
629    pub bundles: Vec<LaunchBundle>,
630    /// One entry per host that has published a capacity reading. The label is
631    /// how a person names the host; it is never a locator or an address.
632    #[serde(default, skip_serializing_if = "Vec::is_empty")]
633    pub hosts: Vec<LaunchHost>,
634    /// The pair the user last saved as their default, which the first setup
635    /// also becomes. Absent when nothing has ever been saved, and never an
636    /// error: a caller that cannot read a preference still has the lists.
637    #[serde(default, skip_serializing_if = "Option::is_none")]
638    pub default: Option<LaunchDefault>,
639}
640
641/// One account this daemon can run work under.
642#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
643#[serde(deny_unknown_fields)]
644pub struct LaunchProfile {
645    pub id: String,
646    /// The harness kind, such as `codex` or `claude`. Which account it is, and
647    /// where its credentials live, stay on the controller.
648    pub harness: String,
649}
650
651/// One runtime template a session can run on.
652#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
653#[serde(deny_unknown_fields)]
654pub struct LaunchTarget {
655    pub id: String,
656    /// The runtime kind, such as `local-bare` or `ssh-podman`.
657    pub kind: String,
658    /// Whether this target needs an existing Git directory on its machine
659    /// instead of provisioning repositories into a workspace.
660    pub requires_project_directory: bool,
661    pub availability: LaunchAvailability,
662    /// What to tell a person when the host did not answer. This repository
663    /// composes the sentence: a probe's own message names hosts and commands
664    /// and stays on the controller.
665    #[serde(default, skip_serializing_if = "Option::is_none")]
666    pub unavailable_reason: Option<String>,
667    /// How a person names the host, when a reading covers this target.
668    #[serde(default, skip_serializing_if = "Option::is_none")]
669    pub host: Option<String>,
670}
671
672/// How much this daemon knows about a target's host.
673///
674/// A reading arrives from a background poll, so it can be absent, old, or
675/// failed. Only `Unavailable` is a statement that the target cannot be used;
676/// `Unknown` means nobody has checked yet, which is the ordinary state
677/// immediately after startup.
678#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
679#[serde(rename_all = "lowercase")]
680pub enum LaunchAvailability {
681    /// A reading covers this target and its last check succeeded.
682    Ready,
683    /// A reading covers this target but is marked stale.
684    Stale,
685    /// A reading covers this target and its last check failed.
686    Unavailable,
687    /// No reading covers this target yet.
688    Unknown,
689}
690
691/// One repository set a managed target can provision.
692#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
693#[serde(deny_unknown_fields)]
694pub struct LaunchBundle {
695    pub id: String,
696    pub primary_repository: String,
697    pub repositories: Vec<LaunchRepository>,
698}
699
700#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
701#[serde(deny_unknown_fields)]
702pub struct LaunchRepository {
703    pub id: String,
704    /// The GitHub source, when this repository has one. A repository sourced
705    /// from a local directory publishes nothing here, because that source is a
706    /// path on the controller.
707    #[serde(default, skip_serializing_if = "Option::is_none")]
708    pub github: Option<String>,
709    pub destination: String,
710}
711
712/// One host or fleet, as much as a caller needs to explain an unavailable
713/// target. The probe's own error text is deliberately absent.
714#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
715#[serde(deny_unknown_fields)]
716pub struct LaunchHost {
717    pub id: String,
718    pub label: String,
719    /// The targets this reading covers.
720    pub targets: Vec<String>,
721    pub stale: bool,
722    pub refreshing: bool,
723    pub has_error: bool,
724}
725
726/// The pair a caller may leave unnamed when it starts a session.
727#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
728#[serde(deny_unknown_fields)]
729pub struct LaunchDefault {
730    pub profile_id: String,
731    pub target_id: String,
732}