Skip to main content

mj_core/state/
session_move.rs

1//! Durable intent for a verified stop followed by destination restoration.
2
3use super::*;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "kebab-case")]
7pub enum ResumeQueueDisposition {
8    Start,
9    Discard,
10}
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct MoveSelection {
15    #[serde(default)]
16    pub clear_resource_allocation: bool,
17    pub session_id: String,
18    pub profile_id: Option<String>,
19    pub target_template_id: Option<String>,
20    pub additional_mounts: Option<Vec<AdditionalMount>>,
21    pub resource_allocation: Option<SessionResourceAllocation>,
22}
23
24/// What moving a local checkout into an isolated workspace will do, shown
25/// before anything is stopped or provisioned.
26///
27/// Every field is read from the host checkout and its remote. The dirty counts
28/// are deliberately not part of a move fingerprint: a running local session has
29/// an agent editing files, so they change under the confirmation.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct RawConversionPreview {
33    /// The checkout that is snapshotted: a managed worktree, or the user's own
34    /// directory when the session opened one directly.
35    pub checkout: PathBuf,
36    /// Where the checkout lands inside the target.
37    pub destination: PathBuf,
38    /// The branch the session continues on, or `None` for a detached head.
39    pub branch: Option<String>,
40    pub fetch_url: String,
41    pub push_urls: Vec<String>,
42    /// The branch the remote's `HEAD` names, which is what a fresh clone
43    /// starts on before the session's own branch is restored.
44    pub default_branch: String,
45    /// Commits reachable from `HEAD` that are on no origin ref, and so have to
46    /// travel in the conversion archive.
47    pub unpushed_commits: u64,
48    pub staged_files: u64,
49    pub unstaged_files: u64,
50    pub untracked_files: u64,
51    pub untracked_bytes: u64,
52    /// True when the session opened the user's own checkout, which stays on
53    /// this machine untouched after the move.
54    pub host_checkout_retained: bool,
55}
56
57impl RawConversionPreview {
58    /// The one line every surface shows: what is cloned, where it lands, which
59    /// branch the session continues on, and where `git push` goes.
60    pub fn summary_line(&self) -> String {
61        let push = if self.push_urls.is_empty() {
62            self.fetch_url.clone()
63        } else {
64            self.push_urls.join(", ")
65        };
66        format!(
67            "Clone {} (default branch {}) into {} on branch {}; push to {push}.",
68            self.fetch_url,
69            self.default_branch,
70            self.destination.display(),
71            self.branch.as_deref().unwrap_or("a detached head"),
72        )
73    }
74
75    /// What a person needs to read before the checkout moves: which
76    /// uncommitted work travels, which commits travel, and what stays behind.
77    ///
78    /// Every surface renders these in its own warning style, so the wording
79    /// lives here rather than in each of the TUI, the CLI, and the browser.
80    pub fn warning_lines(&self) -> Vec<String> {
81        let mut lines = Vec::new();
82        let dirty = self.staged_files + self.unstaged_files + self.untracked_files;
83        if dirty > 0 {
84            lines.push(format!(
85                "{} staged, {} unstaged, and {} untracked {} ({}) will be copied into the container. \
86                 Ignored files such as build output, .env, and node_modules will not.",
87                self.staged_files,
88                self.unstaged_files,
89                self.untracked_files,
90                if dirty == 1 { "file" } else { "files" },
91                format_conversion_bytes(self.untracked_bytes),
92            ));
93        }
94        if self.unpushed_commits > 0 {
95            let mut line = format!(
96                "{} {} not on {} {} in the checkpoint.",
97                self.unpushed_commits,
98                if self.unpushed_commits == 1 {
99                    "commit"
100                } else {
101                    "commits"
102                },
103                self.fetch_url,
104                if self.unpushed_commits == 1 {
105                    "travels"
106                } else {
107                    "travel"
108                },
109            );
110            if self.unpushed_commits > 200 {
111                line.push_str(" That is a large history; consider pushing first.");
112            }
113            lines.push(line);
114        }
115        if self.host_checkout_retained {
116            lines.push(format!(
117                "{} stays on this machine and will no longer track this session. \
118                 Edits made in the container do not come back automatically; \
119                 push the branch or move the session back.",
120                self.checkout.display(),
121            ));
122        }
123        lines
124    }
125}
126
127/// Untracked size as a person reads it. Only KB and MB appear: a checkout's
128/// untracked work is never usefully described in bytes, and anything above a
129/// gigabyte is already a warning in megabytes.
130fn format_conversion_bytes(bytes: u64) -> String {
131    const KB: f64 = 1024.0;
132    const MB: f64 = KB * 1024.0;
133    let bytes = bytes as f64;
134    if bytes < MB {
135        format!("{:.1} KB", bytes / KB)
136    } else {
137        format!("{:.1} MB", bytes / MB)
138    }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(deny_unknown_fields)]
143pub struct MovePreparation {
144    #[serde(default)]
145    pub source_unavailable: bool,
146    /// Present only when this move converts a local checkout into an isolated
147    /// workspace. Boxed because this preparation travels inside several
148    /// request enums whose other variants are far smaller.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub conversion: Option<Box<RawConversionPreview>>,
151    pub selection: MoveSelection,
152    pub source_profile_id: String,
153    pub source_target_template_id: String,
154    pub cross_harness: bool,
155    pub active: bool,
156    pub queued_commands: Vec<MaterializedQueuedPrompt>,
157    pub fingerprint: String,
158    pub operation_id: String,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(deny_unknown_fields)]
163pub struct MoveSessionRequest {
164    pub preparation: MovePreparation,
165    pub queue: Option<ResumeQueueDisposition>,
166    pub acknowledge_interruption: bool,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(deny_unknown_fields)]
171pub struct MoveOutcome {
172    pub operation_id: String,
173    pub session_id: String,
174    pub profile_id: String,
175    pub target_template_id: String,
176    pub outcome: String,
177    pub error: Option<String>,
178    pub recovery: Option<String>,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "snake_case")]
183pub enum MovePhase {
184    Preparing,
185    ClosingSource,
186    ResumingDestination,
187    StartingQueue,
188    Completed,
189    Failed,
190    Cancelled,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(deny_unknown_fields)]
195pub struct MoveOperation {
196    /// Keep the source harness stopped across recovery until destination restoration.
197    #[serde(default)]
198    pub source_checkpoint_only: bool,
199    pub operation_id: String,
200    pub selection: MoveSelection,
201    pub source_profile_id: String,
202    pub source_target_template_id: String,
203    pub source_target: Option<TargetLocator>,
204    pub source_native_session_id: Option<String>,
205    pub source_additional_mounts: Vec<AdditionalMount>,
206    pub source_resource_allocation: Option<SessionResourceAllocation>,
207    pub destination_target: Option<TargetLocator>,
208    pub destination_native_session_id: Option<String>,
209    pub destination_store_id: Option<String>,
210    pub configuration_fingerprint: String,
211    pub checkpoint: Option<CheckpointMetadata>,
212    /// Stopped identity retained across partially written resume conversions.
213    pub recovery_session: Option<SessionRecord>,
214    pub queue: ResumeQueueDisposition,
215    pub phase: MovePhase,
216    /// A durable boundary: once set, never restore or replay on another relay.
217    pub queue_admission_started: bool,
218    pub queue_admission_finished: bool,
219    pub cancellation_requested: bool,
220    pub created_at: String,
221    pub updated_at: String,
222    pub error: Option<String>,
223}
224
225impl MoveOperation {
226    pub fn retains_checkpoint(&self) -> bool {
227        !matches!(self.phase, MovePhase::Completed | MovePhase::Cancelled)
228            || (self.queue_admission_started && !self.queue_admission_finished)
229    }
230
231    pub fn is_active(&self) -> bool {
232        matches!(
233            self.phase,
234            MovePhase::Preparing
235                | MovePhase::ClosingSource
236                | MovePhase::ResumingDestination
237                | MovePhase::StartingQueue
238        )
239    }
240}